Index: frontend/src/api/twelveData.js
===================================================================
--- frontend/src/api/twelveData.js	(revision 6fa5831b8e2ae83049ad95bf899fba2f0a35fbf4)
+++ frontend/src/api/twelveData.js	(revision 6fa5831b8e2ae83049ad95bf899fba2f0a35fbf4)
@@ -0,0 +1,284 @@
+const BASE_URL =
+  import.meta.env.VITE_TWELVE_DATA_BASE_URL ?? "https://api.twelvedata.com";
+
+const API_KEY = import.meta.env.VITE_TWELVE_DATA_API_KEY ?? "";
+
+const CACHE_TTL_MS = 5 * 60_000;
+const MAX_SYMBOLS_PER_REQUEST = 20;
+
+// Cache by Twelve Data symbol (normalized)
+const priceCache = new Map();
+const inFlight = new Map();
+
+function getCachedPrice(twelveSymbol) {
+  const item = priceCache.get(twelveSymbol);
+  if (!item) return null;
+  if (Date.now() - item.ts > CACHE_TTL_MS) return null;
+  return item.price;
+}
+
+function setCachedPrice(twelveSymbol, price) {
+  if (!twelveSymbol || typeof price !== "number" || !Number.isFinite(price)) return;
+  priceCache.set(twelveSymbol, { price, ts: Date.now() });
+}
+
+function parsePriceJson(json, requestedSymbols) {
+  const out = {};
+  if (!json || typeof json !== "object" || Array.isArray(json)) return out;
+
+  // Single symbol response
+  if (json.price !== undefined && requestedSymbols.length === 1) {
+    const n = Number(json.price);
+    if (Number.isFinite(n)) out[requestedSymbols[0]] = n;
+    return out;
+  }
+
+  // Multi symbol response: object keyed by symbol
+  for (const sym of requestedSymbols) {
+    const n = Number(json?.[sym]?.price);
+    if (Number.isFinite(n)) out[sym] = n;
+  }
+
+  return out;
+}
+
+async function fetchBatchPrices(twelveSymbols) {
+  const params = new URLSearchParams();
+  params.set("symbol", twelveSymbols.join(","));
+  params.set("apikey", API_KEY);
+
+  const url = `${BASE_URL}/price?${params.toString()}`;
+  const resp = await fetch(url, { method: "GET" });
+
+  if (!resp.ok) {
+    const text = await resp.text().catch(() => "");
+    throw new Error(`Twelve Data request failed (HTTP ${resp.status}) ${text}`);
+  }
+
+  const json = await resp.json();
+  if (json?.status === "error") {
+    throw new Error(String(json?.message ?? "Twelve Data error"));
+  }
+
+  return parsePriceJson(json, twelveSymbols);
+}
+
+async function fetchSinglePrice(twelveSymbol) {
+  const cached = getCachedPrice(twelveSymbol);
+  if (typeof cached === "number") return cached;
+
+  if (inFlight.has(twelveSymbol)) return inFlight.get(twelveSymbol);
+
+  const p = (async () => {
+    const params = new URLSearchParams();
+    params.set("symbol", twelveSymbol);
+    params.set("apikey", API_KEY);
+    const url = `${BASE_URL}/price?${params.toString()}`;
+    const resp = await fetch(url, { method: "GET" });
+    if (!resp.ok) {
+      const text = await resp.text().catch(() => "");
+      throw new Error(`Twelve Data request failed (HTTP ${resp.status}) ${text}`);
+    }
+    const json = await resp.json();
+    if (json?.status === "error") {
+      throw new Error(String(json?.message ?? "Twelve Data error"));
+    }
+    const n = Number(json?.price);
+    if (!Number.isFinite(n)) throw new Error("Twelve Data returned no price");
+    setCachedPrice(twelveSymbol, n);
+    return n;
+  })();
+
+  inFlight.set(twelveSymbol, p);
+  try {
+    return await p;
+  } finally {
+    inFlight.delete(twelveSymbol);
+  }
+}
+
+async function fetchSingleExchangeRate(pairSymbol) {
+  const cached = getCachedPrice(pairSymbol);
+  if (typeof cached === "number") return cached;
+
+  if (inFlight.has(pairSymbol)) return inFlight.get(pairSymbol);
+
+  const p = (async () => {
+    const params = new URLSearchParams();
+    params.set("symbol", pairSymbol);
+    params.set("apikey", API_KEY);
+    const url = `${BASE_URL}/exchange_rate?${params.toString()}`;
+    const resp = await fetch(url, { method: "GET" });
+    if (!resp.ok) {
+      const text = await resp.text().catch(() => "");
+      throw new Error(`Twelve Data request failed (HTTP ${resp.status}) ${text}`);
+    }
+    const json = await resp.json();
+    if (json?.status === "error") {
+      throw new Error(String(json?.message ?? "Twelve Data error"));
+    }
+
+    const n = Number(json?.rate);
+    if (!Number.isFinite(n)) throw new Error("Twelve Data returned no rate");
+    setCachedPrice(pairSymbol, n);
+    return n;
+  })();
+
+  inFlight.set(pairSymbol, p);
+  try {
+    return await p;
+  } finally {
+    inFlight.delete(pairSymbol);
+  }
+}
+
+export function toTwelveSymbol(symbol) {
+  if (!symbol) return "";
+  const s = String(symbol).trim().toUpperCase();
+  if (!s) return "";
+
+  // Already Twelve format (common for FX/crypto)
+  if (s.includes("/")) return s;
+
+  // Common Yahoo-style crypto tickers
+  const cryptoMap = {
+    "BTC-USD": "BTC/USD",
+    "ETH-USD": "ETH/USD",
+    "SOL-USD": "SOL/USD",
+    "BNB-USD": "BNB/USD",
+    "XRP-USD": "XRP/USD",
+    "ADA-USD": "ADA/USD",
+    "DOGE-USD": "DOGE/USD",
+    "AVAX-USD": "AVAX/USD",
+    "DOT-USD": "DOT/USD",
+    "LINK-USD": "LINK/USD",
+    "MATIC-USD": "MATIC/USD",
+    "LTC-USD": "LTC/USD",
+  };
+
+  if (cryptoMap[s]) return cryptoMap[s];
+
+  // Default: assume it's a stock/ETF symbol (AAPL, SPY, BRK-B, ...)
+  return s;
+}
+
+/**
+ * Fetch current prices for one or more symbols.
+ * Returns an object keyed by *original input symbols* (uppercased), with numeric prices.
+ */
+export async function fetchCurrentPricesBySymbol(symbols) {
+  const input = Array.isArray(symbols) ? symbols : [];
+  const uniqueOriginal = Array.from(
+    new Set(input.map((s) => String(s ?? "").trim()).filter(Boolean)),
+  );
+
+  if (uniqueOriginal.length === 0) return {};
+
+  if (!API_KEY) {
+    throw new Error(
+      "Missing Twelve Data API key (set VITE_TWELVE_DATA_API_KEY in frontend/.env.local)",
+    );
+  }
+
+  // Map original -> twelve
+  const originalToTwelve = new Map();
+  for (const orig of uniqueOriginal) {
+    originalToTwelve.set(orig, toTwelveSymbol(orig));
+  }
+
+  const allTwelveSymbols = Array.from(
+    new Set(Array.from(originalToTwelve.values()).filter(Boolean)),
+  );
+
+  const pairSymbols = allTwelveSymbols.filter((s) => String(s).includes("/"));
+  const priceSymbols = allTwelveSymbols.filter((s) => !String(s).includes("/"));
+
+  // Gather prices with cache + resilient fetch (batch first, fall back to per-symbol)
+  const twelvePrices = {};
+  const missing = [];
+  let hadAnyError = false;
+  let lastErrorMessage = "";
+
+  for (const sym of allTwelveSymbols) {
+    const cached = getCachedPrice(sym);
+    if (typeof cached === "number") {
+      twelvePrices[sym] = cached;
+    } else {
+      missing.push(sym);
+    }
+  }
+
+  // Fetch pair symbols via exchange_rate (generally the most reliable for FX/crypto pairs)
+  for (const sym of pairSymbols) {
+    if (twelvePrices[sym] !== undefined) continue;
+    try {
+      const n = await fetchSingleExchangeRate(sym);
+      if (Number.isFinite(n)) twelvePrices[sym] = n;
+    } catch (e) {
+      hadAnyError = true;
+      lastErrorMessage = e?.message || lastErrorMessage;
+    }
+  }
+
+  // Chunk batch requests to avoid overly long URLs; if a batch fails, we continue
+  // with per-symbol fetches so one invalid symbol doesn't break everything.
+  const missingPrices = missing.filter((s) => priceSymbols.includes(s));
+
+  for (let i = 0; i < missingPrices.length; i += MAX_SYMBOLS_PER_REQUEST) {
+    const chunk = missingPrices.slice(i, i + MAX_SYMBOLS_PER_REQUEST);
+    try {
+      const got = await fetchBatchPrices(chunk);
+      for (const [sym, price] of Object.entries(got)) {
+        const n = Number(price);
+        if (Number.isFinite(n)) {
+          twelvePrices[sym] = n;
+          setCachedPrice(sym, n);
+        }
+      }
+
+      // Some symbols might not come back in a multi-symbol response; fetch them individually.
+      for (const sym of chunk) {
+        if (twelvePrices[sym] !== undefined) continue;
+        try {
+          const n = await fetchSinglePrice(sym);
+          if (Number.isFinite(n)) twelvePrices[sym] = n;
+        } catch (e) {
+          // ignore single-symbol failures
+          hadAnyError = true;
+          lastErrorMessage = e?.message || lastErrorMessage;
+        }
+      }
+    } catch (e) {
+      // Fall back to per-symbol if the whole batch failed.
+      hadAnyError = true;
+      lastErrorMessage = e?.message || lastErrorMessage;
+      await Promise.all(
+        chunk.map(async (sym) => {
+          try {
+            const n = await fetchSinglePrice(sym);
+            if (Number.isFinite(n)) twelvePrices[sym] = n;
+          } catch (e2) {
+            // ignore
+            hadAnyError = true;
+            lastErrorMessage = e2?.message || lastErrorMessage;
+          }
+        }),
+      );
+    }
+  }
+
+  // Convert back to original symbols
+  const out = {};
+  for (const [orig, twelve] of originalToTwelve.entries()) {
+    const p = twelvePrices[twelve];
+    if (typeof p === "number") {
+      out[String(orig).toUpperCase()] = p;
+    }
+  }
+
+  if (Object.keys(out).length === 0 && hadAnyError) {
+    throw new Error(lastErrorMessage || "Could not fetch any current prices");
+  }
+
+  return out;
+}
Index: frontend/src/api/yahooFinance.js
===================================================================
--- frontend/src/api/yahooFinance.js	(revision 6fa5831b8e2ae83049ad95bf899fba2f0a35fbf4)
+++ frontend/src/api/yahooFinance.js	(revision 6fa5831b8e2ae83049ad95bf899fba2f0a35fbf4)
@@ -0,0 +1,148 @@
+import { LOCAL_TICKER_SYMBOLS } from "../data/tickers";
+
+const tickerSearchCache = new Map();
+const TICKER_CACHE_TTL_MS = 15 * 60 * 1000;
+
+const quoteCache = new Map();
+const QUOTE_CACHE_TTL_MS = 5 * 60 * 1000;
+let quoteBackoffUntilMs = 0;
+
+async function tryFetchJson(url) {
+  // Note: We intentionally do NOT set custom headers here.
+  // Custom headers usually trigger a CORS preflight which Yahoo won't allow.
+  const resp = await fetch(url, { method: "GET" });
+  if (!resp.ok) {
+    throw new Error(`Yahoo Finance HTTP ${resp.status}`);
+  }
+  return resp.json();
+}
+
+function toTickerOption(raw) {
+  const symbol = raw?.symbol ? String(raw.symbol) : "";
+  if (!symbol) return null;
+
+  const name = raw?.shortname || raw?.longname || raw?.name || "";
+  const exchange = raw?.exchDisp || raw?.exchange || "";
+
+  return {
+    symbol,
+    name: name ? String(name) : "",
+    exchange: exchange ? String(exchange) : "",
+    quoteType: raw?.quoteType ? String(raw.quoteType) : "",
+  };
+}
+
+/**
+ * Direct Yahoo Finance search (yfinance data source).
+ * Falls back to backend proxy if the browser blocks CORS.
+ */
+export async function searchTickers(query, limit = 20) {
+  const q = String(query ?? "").trim();
+  if (!q) return [];
+  const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 25));
+
+  const cacheKey = `${q.toUpperCase()}:${safeLimit}`;
+  const cached = tickerSearchCache.get(cacheKey);
+  const now = Date.now();
+  if (cached && cached.expiresAt > now) {
+    return cached.value;
+  }
+
+  // Offline/local ticker search: avoids Yahoo 429s and keeps the dropdown reliable.
+  const upperQ = q.toUpperCase();
+  const symbols = Array.isArray(LOCAL_TICKER_SYMBOLS) ? LOCAL_TICKER_SYMBOLS : [];
+  const out = symbols
+    .map((s) => String(s ?? "").trim())
+    .filter(Boolean)
+    .filter((s) => s.toUpperCase().includes(upperQ))
+    .slice(0, safeLimit)
+    .map((symbol) => ({ symbol, name: "", exchange: "" }));
+
+  tickerSearchCache.set(cacheKey, {
+    value: out,
+    expiresAt: now + TICKER_CACHE_TTL_MS,
+  });
+  return out;
+}
+
+/**
+ * Direct Yahoo Finance quotes (yfinance data source).
+ * Returns a map { SYMBOL: priceNumber }.
+ * Falls back to backend proxy if the browser blocks CORS.
+ */
+export async function getQuotesBySymbol(symbols) {
+  const list = Array.isArray(symbols) ? symbols : [];
+  const unique = Array.from(
+    new Set(
+      list
+        .map((s) => String(s ?? "").trim())
+        .filter(Boolean)
+        .map((s) => s.toUpperCase()),
+    ),
+  );
+
+  if (unique.length === 0) return {};
+
+  const parseYahoo = (json) => {
+    const results = Array.isArray(json?.quoteResponse?.result)
+      ? json.quoteResponse.result
+      : [];
+    const map = {};
+    for (const r of results) {
+      const sym = r?.symbol ? String(r.symbol).toUpperCase() : "";
+      const price = r?.regularMarketPrice;
+      if (!sym) continue;
+      if (typeof price !== "number") continue;
+      map[sym] = price;
+    }
+    return map;
+  };
+
+  const now = Date.now();
+  const out = {};
+
+  // Serve cache first.
+  const toFetch = [];
+  for (const sym of unique) {
+    const cached = quoteCache.get(sym);
+    if (cached && cached.expiresAt > now && typeof cached.price === "number") {
+      out[sym] = cached.price;
+    } else {
+      toFetch.push(sym);
+    }
+  }
+
+  // Production builds: browser CORS blocks Yahoo, so quotes are unavailable without a proxy.
+  if (!import.meta.env.DEV) {
+    return out;
+  }
+
+  // Backoff after 429s.
+  if (now < quoteBackoffUntilMs) {
+    return out;
+  }
+
+  if (toFetch.length === 0) {
+    return out;
+  }
+
+  const yahooUrl = `/yahoo/v7/finance/quote?symbols=${encodeURIComponent(
+    toFetch.join(","),
+  )}`;
+
+  try {
+    const json = await tryFetchJson(yahooUrl);
+    const fresh = parseYahoo(json);
+    for (const [sym, price] of Object.entries(fresh)) {
+      quoteCache.set(sym, { price, expiresAt: Date.now() + QUOTE_CACHE_TTL_MS });
+      out[sym] = price;
+    }
+    return out;
+  } catch (e) {
+    const msg = String(e?.message ?? "");
+    if (msg.includes("HTTP 429")) {
+      quoteBackoffUntilMs = Date.now() + 60_000;
+    }
+    return out;
+  }
+}
