feat: ticker validation + SLV/USO/WEAT/CORN/TUR added to ETFs watchlist
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 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,24 @@ def watchlist():
|
|||||||
return 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")
|
@router.get("/macro-regime")
|
||||||
def macro_regime(force: bool = False):
|
def macro_regime(force: bool = False):
|
||||||
"""Macro gauge values + 5-scenario scoring. Cached 15 min."""
|
"""Macro gauge values + 5-scenario scoring. Cached 15 min."""
|
||||||
|
|||||||
@@ -56,24 +56,29 @@ WATCHLIST: Dict[str, List[Dict[str, str]]] = {
|
|||||||
{"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"},
|
{"symbol": "^VIX", "name": "VIX Volatility", "currency": "USD"},
|
||||||
],
|
],
|
||||||
"etfs": [
|
"etfs": [
|
||||||
{"symbol": "SPY", "name": "S&P 500 ETF (SPY)", "currency": "USD"},
|
{"symbol": "SPY", "name": "S&P 500 ETF (SPY)", "currency": "USD"},
|
||||||
{"symbol": "QQQ", "name": "NASDAQ 100 ETF (QQQ)", "currency": "USD"},
|
{"symbol": "QQQ", "name": "NASDAQ 100 ETF (QQQ)", "currency": "USD"},
|
||||||
{"symbol": "IWM", "name": "Russell 2000 ETF (IWM)", "currency": "USD"},
|
{"symbol": "IWM", "name": "Russell 2000 ETF (IWM)", "currency": "USD"},
|
||||||
{"symbol": "TLT", "name": "20Y Treasury ETF (TLT)", "currency": "USD"},
|
{"symbol": "TLT", "name": "20Y Treasury ETF (TLT)", "currency": "USD"},
|
||||||
{"symbol": "IEF", "name": "7-10Y Treasury ETF (IEF)","currency": "USD"},
|
{"symbol": "IEF", "name": "7-10Y Treasury ETF (IEF)", "currency": "USD"},
|
||||||
{"symbol": "HYG", "name": "High Yield Bond (HYG)", "currency": "USD"},
|
{"symbol": "HYG", "name": "High Yield Bond (HYG)", "currency": "USD"},
|
||||||
{"symbol": "GLD", "name": "Gold ETF (GLD)", "currency": "USD"},
|
{"symbol": "GLD", "name": "Gold ETF (GLD)", "currency": "USD"},
|
||||||
{"symbol": "EEM", "name": "EM ETF (EEM)", "currency": "USD"},
|
{"symbol": "SLV", "name": "Silver ETF (SLV)", "currency": "USD"},
|
||||||
{"symbol": "FXI", "name": "China Large Cap (FXI)", "currency": "USD"},
|
{"symbol": "USO", "name": "Oil ETF (USO)", "currency": "USD"},
|
||||||
{"symbol": "EWG", "name": "Germany ETF (EWG)", "currency": "USD"},
|
{"symbol": "EEM", "name": "EM ETF (EEM)", "currency": "USD"},
|
||||||
{"symbol": "EWJ", "name": "Japan ETF (EWJ)", "currency": "USD"},
|
{"symbol": "FXI", "name": "China Large Cap (FXI)", "currency": "USD"},
|
||||||
{"symbol": "EWU", "name": "UK ETF (EWU)", "currency": "USD"},
|
{"symbol": "EWG", "name": "Germany ETF (EWG)", "currency": "USD"},
|
||||||
{"symbol": "EWZ", "name": "Brazil ETF (EWZ)", "currency": "USD"},
|
{"symbol": "EWJ", "name": "Japan ETF (EWJ)", "currency": "USD"},
|
||||||
{"symbol": "XLF", "name": "Financials ETF (XLF)", "currency": "USD"},
|
{"symbol": "EWU", "name": "UK ETF (EWU)", "currency": "USD"},
|
||||||
{"symbol": "SMH", "name": "Semiconductors (SMH)", "currency": "USD"},
|
{"symbol": "EWZ", "name": "Brazil ETF (EWZ)", "currency": "USD"},
|
||||||
{"symbol": "KWEB", "name": "China Internet (KWEB)", "currency": "USD"},
|
{"symbol": "XLF", "name": "Financials ETF (XLF)", "currency": "USD"},
|
||||||
{"symbol": "UUP", "name": "US Dollar ETF (UUP)", "currency": "USD"},
|
{"symbol": "SMH", "name": "Semiconductors (SMH)", "currency": "USD"},
|
||||||
{"symbol": "BIL", "name": "T-Bill 1-3M (BIL)", "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": [
|
"equities": [
|
||||||
{"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"},
|
{"symbol": "XOM", "name": "Exxon Mobil", "currency": "USD"},
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const useAllQuotes = () =>
|
|||||||
refetchInterval: 60_000,
|
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<TickerValidation> =>
|
||||||
|
api.get('/market/validate', { params: { symbol } }).then(r => r.data)
|
||||||
|
|
||||||
export const useHistory = (symbol: string, period = '1y', interval = '1d') =>
|
export const useHistory = (symbol: string, period = '1y', interval = '1d') =>
|
||||||
useQuery<HistoricalCandle[]>({
|
useQuery<HistoricalCandle[]>({
|
||||||
queryKey: ['history', symbol, period, interval],
|
queryKey: ['history', symbol, period, interval],
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { Link } from 'react-router-dom'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
TreePine, Search, ChevronRight, ChevronDown,
|
TreePine, Search, ChevronRight, ChevronDown,
|
||||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink,
|
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useAllPatterns,
|
useAllPatterns,
|
||||||
usePatternTaxonomy,
|
usePatternTaxonomy,
|
||||||
usePatternsByInstrument,
|
usePatternsByInstrument,
|
||||||
useLastScores,
|
useLastScores,
|
||||||
|
validateTicker,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||||
|
|
||||||
@@ -591,7 +592,9 @@ function InstrumentRow({ pattern, searchTerm }: { pattern: Pattern; searchTerm:
|
|||||||
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||||
const [customInput, setCustomInput] = useState('')
|
const [customInput, setCustomInput] = useState('')
|
||||||
|
const [validating, setValidating] = useState(false)
|
||||||
|
const [tickerError, setTickerError] = useState<string | null>(null)
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
// Selected ticker — either clicked from grid or typed
|
// Selected ticker — either clicked from grid or typed
|
||||||
@@ -629,9 +632,24 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
|||||||
)
|
)
|
||||||
}, [uniqueInstruments, activeCategory, searchTerm])
|
}, [uniqueInstruments, activeCategory, searchTerm])
|
||||||
|
|
||||||
const handleCustom = () => {
|
const handleCustom = async () => {
|
||||||
const t = customInput.trim()
|
const sym = customInput.trim().toUpperCase()
|
||||||
if (t) { setSelectedTicker(t); setCustomInput('') }
|
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 (
|
return (
|
||||||
@@ -660,12 +678,25 @@ function InstrumentLens({ patterns }: { patterns: Pattern[] }) {
|
|||||||
placeholder="Search instruments…"
|
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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
<input type="text" value={customInput} onChange={e => setCustomInput(e.target.value)}
|
<div className="flex flex-col gap-1">
|
||||||
onKeyDown={e => e.key === 'Enter' && handleCustom()}
|
<div className="flex gap-1">
|
||||||
placeholder="Custom ticker…"
|
<input type="text" value={customInput}
|
||||||
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" />
|
onChange={e => { setCustomInput(e.target.value); setTickerError(null) }}
|
||||||
<button onClick={handleCustom}
|
onKeyDown={e => e.key === 'Enter' && handleCustom()}
|
||||||
className="px-2.5 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 text-white rounded transition-colors">Go</button>
|
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'
|
||||||
|
)} />
|
||||||
|
<button onClick={handleCustom} disabled={!customInput.trim() || validating}
|
||||||
|
className="px-2.5 py-1.5 text-xs bg-blue-700 hover:bg-blue-600 disabled:opacity-40 text-white rounded transition-colors flex items-center gap-1">
|
||||||
|
{validating ? <><RefreshCw className="w-3 h-3 animate-spin" /> …</> : 'Go'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tickerError && (
|
||||||
|
<p className="text-[9px] text-red-400 max-w-[200px] leading-tight">{tickerError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{selectedTicker && (
|
{selectedTicker && (
|
||||||
<button onClick={() => setSelectedTicker('')}
|
<button onClick={() => setSelectedTicker('')}
|
||||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">Clear</button>
|
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">Clear</button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
useRunPatternLab, useEvaluatePatternLab, useSaveLabPattern,
|
||||||
usePatternLabRuns, useDeleteLabRun,
|
usePatternLabRuns, useDeleteLabRun,
|
||||||
useInstrumentScan, useEvaluateInstrumentScan,
|
useInstrumentScan, useEvaluateInstrumentScan,
|
||||||
|
validateTicker,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import {
|
import {
|
||||||
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
FlaskConical, Play, CheckCircle2, XCircle, MinusCircle,
|
||||||
@@ -146,9 +147,11 @@ function OutcomeRow({ out }: { out: any }) {
|
|||||||
// ── Shared instrument picker ───────────────────────────────────────────────────
|
// ── Shared instrument picker ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: string) => void }) {
|
function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: string) => void }) {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [cat, setCat] = useState<string | null>(null)
|
const [cat, setCat] = useState<string | null>(null)
|
||||||
const [custom, setCustom] = useState('')
|
const [custom, setCustom] = useState('')
|
||||||
|
const [validating, setValidating] = useState(false)
|
||||||
|
const [tickerError, setTickerError] = useState<string | null>(null)
|
||||||
|
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const unique = INSTRUMENTS.filter(i => { if (seen.has(i.ticker)) return false; seen.add(i.ticker); return true })
|
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
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Category pills */}
|
{/* Category pills */}
|
||||||
@@ -186,10 +209,24 @@ function InstrumentPicker({ onSelect }: { onSelect: (ticker: string, name: strin
|
|||||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search…"
|
<input value={search} onChange={e => 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" />
|
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" />
|
||||||
</div>
|
</div>
|
||||||
<input value={custom} onChange={e => setCustom(e.target.value)}
|
<div className="flex flex-col gap-1">
|
||||||
onKeyDown={e => e.key === 'Enter' && custom.trim() && onSelect(custom.trim(), custom.trim())}
|
<div className="flex gap-1">
|
||||||
placeholder="Custom ticker"
|
<input value={custom} onChange={e => { setCustom(e.target.value); setTickerError(null) }}
|
||||||
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" />
|
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'
|
||||||
|
)} />
|
||||||
|
<button onClick={handleCustom} disabled={!custom.trim() || validating}
|
||||||
|
className="px-2 py-1.5 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-400 hover:text-slate-200 hover:border-violet-500 disabled:opacity-40 transition-colors">
|
||||||
|
{validating ? <RefreshCw className="w-3 h-3 animate-spin" /> : '→'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tickerError && (
|
||||||
|
<p className="text-[9px] text-red-400 max-w-[140px] leading-tight">{tickerError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<div className="grid grid-cols-3 gap-1 max-h-60 overflow-y-auto pr-1">
|
<div className="grid grid-cols-3 gap-1 max-h-60 overflow-y-auto pr-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user