feat: dynamic market ticker watchlist (Markets page)
Add ability to add/remove custom tickers (e.g. EURCHF=X) on the Markets & Prices page without editing config. Tickers are validated via yfinance, persisted in market_watchlist SQLite table, merged into the quotes feed as a 'custom' group, and shown in a dedicated tab with per-card remove buttons. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,38 @@ def watchlist():
|
|||||||
return WATCHLIST
|
return WATCHLIST
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/custom-tickers")
|
||||||
|
def list_custom_tickers():
|
||||||
|
from services.database import get_market_custom_tickers
|
||||||
|
return get_market_custom_tickers()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/custom-tickers/{ticker}")
|
||||||
|
def add_custom_ticker(ticker: str):
|
||||||
|
from services.data_fetcher import get_quote
|
||||||
|
from services.database import add_market_custom_ticker
|
||||||
|
import yfinance as yf
|
||||||
|
ticker = ticker.strip().upper()
|
||||||
|
q = get_quote(ticker)
|
||||||
|
if not q or not q.get("price"):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(400, f"Ticker '{ticker}' not found on yfinance")
|
||||||
|
try:
|
||||||
|
info = yf.Ticker(ticker).fast_info
|
||||||
|
name = getattr(info, "long_name", None) or getattr(info, "short_name", None) or ticker
|
||||||
|
except Exception:
|
||||||
|
name = ticker
|
||||||
|
add_market_custom_ticker(ticker, name)
|
||||||
|
return {"ticker": ticker, "name": name, "price": q["price"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/custom-tickers/{ticker}")
|
||||||
|
def remove_custom_ticker(ticker: str):
|
||||||
|
from services.database import remove_market_custom_ticker
|
||||||
|
remove_market_custom_ticker(ticker.strip().upper())
|
||||||
|
return {"removed": ticker.upper()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/validate")
|
@router.get("/validate")
|
||||||
def validate_ticker(symbol: str = Query(..., description="Ticker to validate against yfinance")):
|
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."""
|
"""Check if a ticker is fetchable. Returns valid=True + live price/name, or valid=False + reason."""
|
||||||
|
|||||||
@@ -136,6 +136,24 @@ def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]:
|
|||||||
q["asset_class"] = asset_class
|
q["asset_class"] = asset_class
|
||||||
quotes.append(q)
|
quotes.append(q)
|
||||||
result[asset_class] = quotes
|
result[asset_class] = quotes
|
||||||
|
|
||||||
|
# User-added custom tickers
|
||||||
|
try:
|
||||||
|
from services.database import get_market_custom_tickers
|
||||||
|
custom_entries = get_market_custom_tickers()
|
||||||
|
if custom_entries:
|
||||||
|
custom_quotes = []
|
||||||
|
for entry in custom_entries:
|
||||||
|
q = get_quote(entry["ticker"])
|
||||||
|
if q:
|
||||||
|
q["name"] = entry["name"] or entry["ticker"]
|
||||||
|
q["asset_class"] = "custom"
|
||||||
|
custom_quotes.append(q)
|
||||||
|
if custom_quotes:
|
||||||
|
result["custom"] = custom_quotes
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ def init_db():
|
|||||||
# Regime / counter-scenario architecture
|
# Regime / counter-scenario architecture
|
||||||
"ALTER TABLE custom_patterns ADD COLUMN regime_tag TEXT",
|
"ALTER TABLE custom_patterns ADD COLUMN regime_tag TEXT",
|
||||||
"ALTER TABLE custom_patterns ADD COLUMN counter_of TEXT",
|
"ALTER TABLE custom_patterns ADD COLUMN counter_of TEXT",
|
||||||
|
# Markets & Prices — user-defined watchlist
|
||||||
|
"""CREATE TABLE IF NOT EXISTS market_watchlist (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
added_at TEXT DEFAULT (datetime('now'))
|
||||||
|
)""",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
@@ -2307,6 +2313,42 @@ def remove_watchlist_ticker(ticker: str) -> bool:
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
# ── Market Watchlist (user-added tickers for Markets page) ───────────────────
|
||||||
|
|
||||||
|
def get_market_custom_tickers() -> List[Dict]:
|
||||||
|
conn = get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ticker, name, added_at FROM market_watchlist ORDER BY added_at ASC"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def add_market_custom_ticker(ticker: str, name: str = "") -> bool:
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO market_watchlist (ticker, name) VALUES (?, ?)",
|
||||||
|
(ticker.upper(), name),
|
||||||
|
)
|
||||||
|
inserted = conn.total_changes > 0
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
inserted = False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def remove_market_custom_ticker(ticker: str) -> bool:
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute("DELETE FROM market_watchlist WHERE ticker=?", (ticker.upper(),))
|
||||||
|
changed = conn.total_changes > 0
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
# ── System Logs ───────────────────────────────────────────────────────────────
|
# ── System Logs ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def log_system_event(
|
def log_system_event(
|
||||||
|
|||||||
@@ -20,6 +20,34 @@ export type TickerValidation = { valid: boolean; symbol: string; name?: string;
|
|||||||
export const validateTicker = (symbol: string): Promise<TickerValidation> =>
|
export const validateTicker = (symbol: string): Promise<TickerValidation> =>
|
||||||
api.get('/market/validate', { params: { symbol } }).then(r => r.data)
|
api.get('/market/validate', { params: { symbol } }).then(r => r.data)
|
||||||
|
|
||||||
|
export const useMarketCustomTickers = () =>
|
||||||
|
useQuery<{ ticker: string; name: string; added_at: string }[]>({
|
||||||
|
queryKey: ['market-custom-tickers'],
|
||||||
|
queryFn: () => api.get('/market/custom-tickers').then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useAddMarketTicker = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ticker: string) => api.post(`/market/custom-tickers/${encodeURIComponent(ticker)}`).then(r => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['market-custom-tickers'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['quotes'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRemoveMarketTicker = () => {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (ticker: string) => api.delete(`/market/custom-tickers/${encodeURIComponent(ticker)}`).then(r => r.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['market-custom-tickers'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['quotes'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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],
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useAllQuotes, useHistory } from '../hooks/useApi'
|
import { useAllQuotes, useHistory, useAddMarketTicker, useRemoveMarketTicker } from '../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import type { Quote, AssetClass } from '../types'
|
import type { Quote, AssetClass } from '../types'
|
||||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||||
import { TrendingUp, TrendingDown, BarChart2, RefreshCw, Search } from 'lucide-react'
|
import { TrendingUp, TrendingDown, BarChart2, RefreshCw, Search, Plus, X } from 'lucide-react'
|
||||||
|
|
||||||
|
type ExtAssetClass = AssetClass | 'custom'
|
||||||
|
|
||||||
const ASSET_CLASSES: { key: AssetClass; label: string; emoji: string }[] = [
|
const ASSET_CLASSES: { key: ExtAssetClass; label: string; emoji: string }[] = [
|
||||||
{ key: 'energy', label: 'Energy', emoji: '⛽' },
|
{ key: 'energy', label: 'Energy', emoji: '⛽' },
|
||||||
{ key: 'metals', label: 'Metals', emoji: '🥇' },
|
{ key: 'metals', label: 'Metals', emoji: '🥇' },
|
||||||
{ key: 'agriculture', label: 'Agriculture', emoji: '🌾' },
|
{ key: 'agriculture', label: 'Agriculture', emoji: '🌾' },
|
||||||
{ key: 'indices', label: 'Indices', emoji: '📊' },
|
{ key: 'indices', label: 'Indices', emoji: '📊' },
|
||||||
{ key: 'etfs', label: 'ETFs', emoji: '🗂' },
|
{ key: 'etfs', label: 'ETFs', emoji: '🗂' },
|
||||||
{ key: 'equities', label: 'Equities', emoji: '📈' },
|
{ key: 'equities', label: 'Equities', emoji: '📈' },
|
||||||
{ key: 'forex', label: 'Forex', emoji: '💱' },
|
{ key: 'forex', label: 'Forex', emoji: '💱' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function QuoteCard({ q, selected, onClick }: { q: Quote; selected: boolean; onClick: () => void }) {
|
function QuoteCard({ q, selected, onClick, onRemove }: { q: Quote; selected: boolean; onClick: () => void; onRemove?: () => void }) {
|
||||||
if (!q.price) return null
|
if (!q.price) return null
|
||||||
const pos = q.change_pct >= 0
|
const pos = q.change_pct >= 0
|
||||||
return (
|
return (
|
||||||
@@ -33,11 +34,19 @@ function QuoteCard({ q, selected, onClick }: { q: Quote; selected: boolean; onCl
|
|||||||
<div className="text-xs text-white font-semibold truncate">{q.name || q.symbol}</div>
|
<div className="text-xs text-white font-semibold truncate">{q.name || q.symbol}</div>
|
||||||
<div className="text-xs text-slate-600">{q.symbol}</div>
|
<div className="text-xs text-slate-600">{q.symbol}</div>
|
||||||
</div>
|
</div>
|
||||||
{pos ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
{pos ? (
|
||||||
) : (
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
<TrendingDown className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
) : (
|
||||||
)}
|
<TrendingDown className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
{onRemove && (
|
||||||
|
<button onClick={e => { e.stopPropagation(); onRemove() }}
|
||||||
|
className="text-slate-700 hover:text-red-400 transition-colors" title="Remove">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex justify-between items-end">
|
<div className="mt-2 flex justify-between items-end">
|
||||||
<span className="text-sm font-bold text-white font-mono">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
|
<span className="text-sm font-bold text-white font-mono">{q.price.toFixed(q.price > 100 ? 2 : 4)}</span>
|
||||||
@@ -150,10 +159,30 @@ function PriceChart({ symbol, name }: { symbol: string; name: string }) {
|
|||||||
export default function Markets() {
|
export default function Markets() {
|
||||||
const { data: allQuotes, isLoading, refetch } = useAllQuotes()
|
const { data: allQuotes, isLoading, refetch } = useAllQuotes()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [activeClass, setActiveClass] = useState<AssetClass>('energy')
|
const [activeClass, setActiveClass] = useState<ExtAssetClass>('energy')
|
||||||
const [selectedSymbol, setSelectedSymbol] = useState<{ symbol: string; name: string } | null>(null)
|
const [selectedSymbol, setSelectedSymbol] = useState<{ symbol: string; name: string } | null>(null)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [addInput, setAddInput] = useState('')
|
||||||
|
const [addError, setAddError] = useState('')
|
||||||
|
const [showAddInput, setShowAddInput] = useState(false)
|
||||||
const initialSymbol = useRef(searchParams.get('symbol'))
|
const initialSymbol = useRef(searchParams.get('symbol'))
|
||||||
|
const { mutateAsync: addTicker, isPending: adding } = useAddMarketTicker()
|
||||||
|
const { mutate: removeTicker } = useRemoveMarketTicker()
|
||||||
|
|
||||||
|
const handleAddTicker = async () => {
|
||||||
|
const sym = addInput.trim().toUpperCase()
|
||||||
|
if (!sym) return
|
||||||
|
setAddError('')
|
||||||
|
try {
|
||||||
|
await addTicker(sym)
|
||||||
|
setAddInput('')
|
||||||
|
setShowAddInput(false)
|
||||||
|
setActiveClass('custom')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = (e as { message?: string })?.message ?? 'Ticker not found'
|
||||||
|
setAddError(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select symbol coming from another page (e.g. Portfolio ticker link)
|
// Auto-select symbol coming from another page (e.g. Portfolio ticker link)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,7 +192,7 @@ export default function Markets() {
|
|||||||
for (const [cls, quotes] of Object.entries(allQuotes)) {
|
for (const [cls, quotes] of Object.entries(allQuotes)) {
|
||||||
const match = (quotes as Quote[]).find(q => q.symbol.toUpperCase() === sym.toUpperCase())
|
const match = (quotes as Quote[]).find(q => q.symbol.toUpperCase() === sym.toUpperCase())
|
||||||
if (match) {
|
if (match) {
|
||||||
setActiveClass(cls as AssetClass)
|
setActiveClass(cls as ExtAssetClass)
|
||||||
setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol })
|
setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -221,6 +250,60 @@ export default function Markets() {
|
|||||||
<span>{emoji}</span> {label}
|
<span>{emoji}</span> {label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{(allQuotes?.['custom']?.length ?? 0) > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveClass('custom'); setSelectedSymbol(null); setSearchQuery('') }}
|
||||||
|
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-sm transition-all', {
|
||||||
|
'bg-purple-600 border-purple-500 text-white': activeClass === 'custom',
|
||||||
|
'border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200': activeClass !== 'custom',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span>⭐</span> Custom
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add custom ticker button + inline input */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showAddInput ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={addInput}
|
||||||
|
onChange={e => { setAddInput(e.target.value); setAddError('') }}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') handleAddTicker()
|
||||||
|
if (e.key === 'Escape') { setShowAddInput(false); setAddInput(''); setAddError('') }
|
||||||
|
}}
|
||||||
|
placeholder="e.g. EURCHF=X"
|
||||||
|
className="bg-dark-700 border border-slate-600 rounded px-2.5 py-1 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 w-36 font-mono uppercase"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddTicker}
|
||||||
|
disabled={adding}
|
||||||
|
className="px-2.5 py-1 rounded bg-purple-600 hover:bg-purple-500 text-white text-xs disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{adding ? <RefreshCw className="w-3 h-3 animate-spin" /> : 'Add'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAddInput(false); setAddInput(''); setAddError('') }}
|
||||||
|
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{addError && <span className="text-red-400 text-xs max-w-48 truncate">{addError}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddInput(true)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1.5 rounded border border-dashed border-slate-600 text-slate-500 hover:border-purple-500 hover:text-purple-400 text-sm transition-all"
|
||||||
|
title="Add custom ticker"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add ticker
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative ml-auto">
|
<div className="relative ml-auto">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500 pointer-events-none" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
@@ -267,8 +350,9 @@ export default function Markets() {
|
|||||||
{/* Quote grid */}
|
{/* Quote grid */}
|
||||||
<div className="col-span-1 space-y-2">
|
<div className="col-span-1 space-y-2">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
{ASSET_CLASSES.find(c => c.key === activeClass)?.emoji}{' '}
|
{activeClass === 'custom'
|
||||||
{ASSET_CLASSES.find(c => c.key === activeClass)?.label}
|
? '⭐ Custom'
|
||||||
|
: `${ASSET_CLASSES.find(c => c.key === activeClass)?.emoji ?? ''} ${ASSET_CLASSES.find(c => c.key === activeClass)?.label ?? ''}`}
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
[1,2,3,4].map(i => <div key={i} className="card-sm animate-pulse h-16 bg-dark-700"></div>)
|
[1,2,3,4].map(i => <div key={i} className="card-sm animate-pulse h-16 bg-dark-700"></div>)
|
||||||
@@ -279,6 +363,7 @@ export default function Markets() {
|
|||||||
q={q}
|
q={q}
|
||||||
selected={selectedSymbol?.symbol === q.symbol}
|
selected={selectedSymbol?.symbol === q.symbol}
|
||||||
onClick={() => setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })}
|
onClick={() => setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })}
|
||||||
|
onRemove={activeClass === 'custom' ? () => removeTicker(q.symbol) : undefined}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user