From 6cca7f66b607456ff184cd5bbc43e3a7da66b612 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Mon, 22 Jun 2026 20:15:26 +0200 Subject: [PATCH] feat: ticker validation + SLV/USO/WEAT/CORN/TUR added to ETFs watchlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/market/validate?symbol= — validates ticker against yfinance, returns {valid, name, price} or {valid: false, reason: 'helpful message'} - Added SLV, USO, WEAT, CORN, TUR to ETFs WATCHLIST category Frontend: - validateTicker() async helper exported from useApi.ts - InstrumentPicker (PatternLab): custom ticker field now validates before selecting Shows spinner while checking, red error message if not found on yfinance - InstrumentLens (PatternExplorer): same validation on Go button + Enter key Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/market_data.py | 18 +++++++++ backend/services/data_fetcher.py | 41 +++++++++++--------- frontend/src/hooks/useApi.ts | 4 ++ frontend/src/pages/PatternExplorer.tsx | 53 ++++++++++++++++++++------ frontend/src/pages/PatternLab.tsx | 51 +++++++++++++++++++++---- 5 files changed, 131 insertions(+), 36 deletions(-) diff --git a/backend/routers/market_data.py b/backend/routers/market_data.py index a4f9d94..c005769 100644 --- a/backend/routers/market_data.py +++ b/backend/routers/market_data.py @@ -39,6 +39,24 @@ def watchlist(): return WATCHLIST +@router.get("/validate") +def validate_ticker(symbol: str = Query(..., description="Ticker to validate against yfinance")): + """Check if a ticker is fetchable. Returns valid=True + live price/name, or valid=False + reason.""" + from services.data_fetcher import get_quote + import yfinance as yf + symbol = symbol.strip().upper() + q = get_quote(symbol) + if q and q.get("price"): + # Try to get a display name + try: + info = yf.Ticker(symbol).fast_info + name = getattr(info, "long_name", None) or getattr(info, "short_name", None) or symbol + except Exception: + name = symbol + return {"valid": True, "symbol": symbol, "name": name, "price": q["price"], "change_pct": q.get("change_pct")} + return {"valid": False, "symbol": symbol, "reason": f"Ticker '{symbol}' not found on yfinance — check the symbol (e.g. GC=F for Gold, ^GSPC for S&P 500)"} + + @router.get("/macro-regime") def macro_regime(force: bool = False): """Macro gauge values + 5-scenario scoring. Cached 15 min.""" diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 96f183a..3ce75cb 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -56,24 +56,29 @@ WATCHLIST: Dict[str, List[Dict[str, str]]] = { {"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"}, ], "etfs": [ - {"symbol": "SPY", "name": "S&P 500 ETF (SPY)", "currency": "USD"}, - {"symbol": "QQQ", "name": "NASDAQ 100 ETF (QQQ)", "currency": "USD"}, - {"symbol": "IWM", "name": "Russell 2000 ETF (IWM)", "currency": "USD"}, - {"symbol": "TLT", "name": "20Y Treasury ETF (TLT)", "currency": "USD"}, - {"symbol": "IEF", "name": "7-10Y Treasury ETF (IEF)","currency": "USD"}, - {"symbol": "HYG", "name": "High Yield Bond (HYG)", "currency": "USD"}, - {"symbol": "GLD", "name": "Gold ETF (GLD)", "currency": "USD"}, - {"symbol": "EEM", "name": "EM ETF (EEM)", "currency": "USD"}, - {"symbol": "FXI", "name": "China Large Cap (FXI)", "currency": "USD"}, - {"symbol": "EWG", "name": "Germany ETF (EWG)", "currency": "USD"}, - {"symbol": "EWJ", "name": "Japan ETF (EWJ)", "currency": "USD"}, - {"symbol": "EWU", "name": "UK ETF (EWU)", "currency": "USD"}, - {"symbol": "EWZ", "name": "Brazil ETF (EWZ)", "currency": "USD"}, - {"symbol": "XLF", "name": "Financials ETF (XLF)", "currency": "USD"}, - {"symbol": "SMH", "name": "Semiconductors (SMH)", "currency": "USD"}, - {"symbol": "KWEB", "name": "China Internet (KWEB)", "currency": "USD"}, - {"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"}, - {"symbol": "BIL", "name": "T-Bill 1-3M (BIL)", "currency": "USD"}, + {"symbol": "SPY", "name": "S&P 500 ETF (SPY)", "currency": "USD"}, + {"symbol": "QQQ", "name": "NASDAQ 100 ETF (QQQ)", "currency": "USD"}, + {"symbol": "IWM", "name": "Russell 2000 ETF (IWM)", "currency": "USD"}, + {"symbol": "TLT", "name": "20Y Treasury ETF (TLT)", "currency": "USD"}, + {"symbol": "IEF", "name": "7-10Y Treasury ETF (IEF)", "currency": "USD"}, + {"symbol": "HYG", "name": "High Yield Bond (HYG)", "currency": "USD"}, + {"symbol": "GLD", "name": "Gold ETF (GLD)", "currency": "USD"}, + {"symbol": "SLV", "name": "Silver ETF (SLV)", "currency": "USD"}, + {"symbol": "USO", "name": "Oil ETF (USO)", "currency": "USD"}, + {"symbol": "EEM", "name": "EM ETF (EEM)", "currency": "USD"}, + {"symbol": "FXI", "name": "China Large Cap (FXI)", "currency": "USD"}, + {"symbol": "EWG", "name": "Germany ETF (EWG)", "currency": "USD"}, + {"symbol": "EWJ", "name": "Japan ETF (EWJ)", "currency": "USD"}, + {"symbol": "EWU", "name": "UK ETF (EWU)", "currency": "USD"}, + {"symbol": "EWZ", "name": "Brazil ETF (EWZ)", "currency": "USD"}, + {"symbol": "XLF", "name": "Financials ETF (XLF)", "currency": "USD"}, + {"symbol": "SMH", "name": "Semiconductors (SMH)", "currency": "USD"}, + {"symbol": "KWEB", "name": "China Internet (KWEB)", "currency": "USD"}, + {"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"}, + {"symbol": "BIL", "name": "T-Bill 1-3M (BIL)", "currency": "USD"}, + {"symbol": "TUR", "name": "Turkey ETF (TUR)", "currency": "USD"}, + {"symbol": "WEAT", "name": "Wheat ETF (WEAT)", "currency": "USD"}, + {"symbol": "CORN", "name": "Corn ETF (CORN)", "currency": "USD"}, ], "equities": [ {"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"}, diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 93ec470..983589c 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -16,6 +16,10 @@ export const useAllQuotes = () => refetchInterval: 60_000, }) +export type TickerValidation = { valid: boolean; symbol: string; name?: string; price?: number; change_pct?: number; reason?: string } +export const validateTicker = (symbol: string): Promise => + api.get('/market/validate', { params: { symbol } }).then(r => r.data) + export const useHistory = (symbol: string, period = '1y', interval = '1d') => useQuery({ queryKey: ['history', symbol, period, interval], diff --git a/frontend/src/pages/PatternExplorer.tsx b/frontend/src/pages/PatternExplorer.tsx index 654cfea..153e1cf 100644 --- a/frontend/src/pages/PatternExplorer.tsx +++ b/frontend/src/pages/PatternExplorer.tsx @@ -3,13 +3,14 @@ import { Link } from 'react-router-dom' import clsx from 'clsx' import { TreePine, Search, ChevronRight, ChevronDown, - TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, + TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw, } from 'lucide-react' import { useAllPatterns, usePatternTaxonomy, usePatternsByInstrument, useLastScores, + validateTicker, } from '../hooks/useApi' import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments' @@ -591,7 +592,9 @@ function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm: function InstrumentLens({ patterns }: { patterns: Pattern[] }) { const [searchTerm, setSearchTerm] = useState('') const [activeCategory, setActiveCategory] = useState(null) - const [customInput, setCustomInput] = useState('') + const [customInput, setCustomInput] = useState('') + const [validating, setValidating] = useState(false) + const [tickerError, setTickerError] = useState(null) const debounceRef = useRef | null>(null) // Selected ticker — either clicked from grid or typed @@ -629,9 +632,24 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) { ) }, [uniqueInstruments, activeCategory, searchTerm]) - const handleCustom = () => { - const t = customInput.trim() - if (t) { setSelectedTicker(t); setCustomInput('') } + const handleCustom = async () => { + const sym = customInput.trim().toUpperCase() + if (!sym) return + setTickerError(null) + setValidating(true) + try { + const res = await validateTicker(sym) + if (res.valid) { + setSelectedTicker(sym) + setCustomInput('') + } else { + setTickerError(res.reason || `Ticker '${sym}' not found`) + } + } catch { + setTickerError(`Could not validate '${sym}' — check your connection`) + } finally { + setValidating(false) + } } return ( @@ -660,12 +678,25 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) { placeholder="Search instruments…" className="w-full pl-8 pr-3 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-600" /> - setCustomInput(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleCustom()} - placeholder="Custom ticker…" - className="w-32 px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-blue-600 font-mono" /> - +
+
+ { setCustomInput(e.target.value); setTickerError(null) }} + onKeyDown={e => e.key === 'Enter' && handleCustom()} + placeholder="Custom ticker…" + className={clsx( + 'w-32 px-2 py-1.5 text-xs bg-dark-700 border rounded text-slate-200 placeholder-slate-600 focus:outline-none font-mono uppercase', + tickerError ? 'border-red-500/70 focus:border-red-400' : 'border-slate-700/50 focus:border-blue-600' + )} /> + +
+ {tickerError && ( +

{tickerError}

+ )} +
{selectedTicker && ( diff --git a/frontend/src/pages/PatternLab.tsx b/frontend/src/pages/PatternLab.tsx index 445c609..5b979df 100644 --- a/frontend/src/pages/PatternLab.tsx +++ b/frontend/src/pages/PatternLab.tsx @@ -3,6 +3,7 @@ import { useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern, usePatternLabRuns, useDeleteLabRun, useInstrumentScan, useEvaluateInstrumentScan, + validateTicker, } from '../hooks/useApi' import { FlaskConical, Play, CheckCircle2, XCircle, MinusCircle, @@ -146,9 +147,11 @@ function OutcomeRow({ out }: { out: any }) { // ── Shared instrument picker ─────────────────────────────────────────────────── function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: string) => void }) { - const [search, setSearch] = useState('') - const [cat, setCat] = useState(null) - const [custom, setCustom] = useState('') + const [search, setSearch] = useState('') + const [cat, setCat] = useState(null) + const [custom, setCustom] = useState('') + const [validating, setValidating] = useState(false) + const [tickerError, setTickerError] = useState(null) const seen = new Set() const unique = INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true }) @@ -162,6 +165,26 @@ function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: strin return true }) + const handleCustom = async () => { + const sym = custom.trim().toUpperCase() + if (!sym) return + setTickerError(null) + setValidating(true) + try { + const res = await validateTicker(sym) + if (res.valid) { + onSelect(sym, res.name || sym) + setCustom('') + } else { + setTickerError(res.reason || `Ticker '${sym}' not found`) + } + } catch { + setTickerError(`Could not validate '${sym}' — check your connection`) + } finally { + setValidating(false) + } + } + return (
{/* Category pills */} @@ -186,10 +209,24 @@ function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: strin setSearch(e.target.value)} placeholder="Search…" className="w-full pl-6 pr-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-200 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
- setCustom(e.target.value)} - onKeyDown={e => e.key === 'Enter' && custom.trim() && onSelect(custom.trim(), custom.trim())} - placeholder="Custom ticker" - className="w-28 px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded font-mono text-slate-200 placeholder-slate-600 focus:outline-none focus:border-violet-500" /> +
+
+ { setCustom(e.target.value); setTickerError(null) }} + onKeyDown={e => e.key === 'Enter' && handleCustom()} + placeholder="Custom ticker" + className={clsx( + 'w-28 px-2 py-1.5 text-xs bg-dark-700 border rounded font-mono text-slate-200 placeholder-slate-600 focus:outline-none uppercase', + tickerError ? 'border-red-500/70 focus:border-red-400' : 'border-slate-700/50 focus:border-violet-500' + )} /> + +
+ {tickerError && ( +

{tickerError}

+ )} +
{/* Grid */}