diff --git a/backend/routers/market_data.py b/backend/routers/market_data.py index c005769..b8d8197 100644 --- a/backend/routers/market_data.py +++ b/backend/routers/market_data.py @@ -39,6 +39,38 @@ def 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") 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.""" diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 3ce75cb..ebebc12 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -136,6 +136,24 @@ def get_all_quotes() -> Dict[str, List[Dict[str, Any]]]: q["asset_class"] = asset_class quotes.append(q) 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 diff --git a/backend/services/database.py b/backend/services/database.py index b893c73..8eaf8b8 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -91,6 +91,12 @@ def init_db(): # Regime / counter-scenario architecture "ALTER TABLE custom_patterns ADD COLUMN regime_tag 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: c.execute(_sql) @@ -2307,6 +2313,42 @@ def remove_watchlist_ticker(ticker: str) -> bool: 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 ─────────────────────────────────────────────────────────────── def log_system_event( diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 58d2ccc..7a0744e 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -20,6 +20,34 @@ export type TickerValidation = { valid: boolean; symbol: string; name?: string; export const validateTicker = (symbol: string): Promise => 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') => useQuery({ queryKey: ['history', symbol, period, interval], diff --git a/frontend/src/pages/Markets.tsx b/frontend/src/pages/Markets.tsx index fa995ab..01d0006 100644 --- a/frontend/src/pages/Markets.tsx +++ b/frontend/src/pages/Markets.tsx @@ -1,23 +1,24 @@ import { useState, useEffect, useMemo, useRef } from 'react' 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 type { Quote, AssetClass } from '../types' 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 }[] = [ - { key: 'energy', label: 'Energy', emoji: '⛽' }, - { key: 'metals', label: 'Metals', emoji: '🥇' }, +const ASSET_CLASSES: { key: ExtAssetClass; label: string; emoji: string }[] = [ + { key: 'energy', label: 'Energy', emoji: '⛽' }, + { key: 'metals', label: 'Metals', emoji: '🥇' }, { key: 'agriculture', label: 'Agriculture', emoji: '🌾' }, - { key: 'indices', label: 'Indices', emoji: '📊' }, - { key: 'etfs', label: 'ETFs', emoji: '🗂' }, - { key: 'equities', label: 'Equities', emoji: '📈' }, - { key: 'forex', label: 'Forex', emoji: '💱' }, + { key: 'indices', label: 'Indices', emoji: '📊' }, + { key: 'etfs', label: 'ETFs', emoji: '🗂' }, + { key: 'equities', label: 'Equities', 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 const pos = q.change_pct >= 0 return ( @@ -33,11 +34,19 @@ function QuoteCard({ q, selected, onClick }: { q: Quote; selected: boolean; onCl
{q.name || q.symbol}
{q.symbol}
- {pos ? ( - - ) : ( - - )} +
+ {pos ? ( + + ) : ( + + )} + {onRemove && ( + + )} +
{q.price.toFixed(q.price > 100 ? 2 : 4)} @@ -150,10 +159,30 @@ function PriceChart({ symbol, name }: { symbol: string; name: string }) { export default function Markets() { const { data: allQuotes, isLoading, refetch } = useAllQuotes() const [searchParams, setSearchParams] = useSearchParams() - const [activeClass, setActiveClass] = useState('energy') + const [activeClass, setActiveClass] = useState('energy') const [selectedSymbol, setSelectedSymbol] = useState<{ symbol: string; name: string } | null>(null) const [searchQuery, setSearchQuery] = useState('') + const [addInput, setAddInput] = useState('') + const [addError, setAddError] = useState('') + const [showAddInput, setShowAddInput] = useState(false) 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) useEffect(() => { @@ -163,7 +192,7 @@ export default function Markets() { for (const [cls, quotes] of Object.entries(allQuotes)) { const match = (quotes as Quote[]).find(q => q.symbol.toUpperCase() === sym.toUpperCase()) if (match) { - setActiveClass(cls as AssetClass) + setActiveClass(cls as ExtAssetClass) setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol }) break } @@ -221,6 +250,60 @@ export default function Markets() { {emoji} {label} ))} + {(allQuotes?.['custom']?.length ?? 0) > 0 && ( + + )} + + {/* Add custom ticker button + inline input */} +
+ {showAddInput ? ( +
+ { 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" + /> + + + {addError && {addError}} +
+ ) : ( + + )} +
+
- {ASSET_CLASSES.find(c => c.key === activeClass)?.emoji}{' '} - {ASSET_CLASSES.find(c => c.key === activeClass)?.label} + {activeClass === 'custom' + ? '⭐ Custom' + : `${ASSET_CLASSES.find(c => c.key === activeClass)?.emoji ?? ''} ${ASSET_CLASSES.find(c => c.key === activeClass)?.label ?? ''}`}
{isLoading ? ( [1,2,3,4].map(i =>
) @@ -279,6 +363,7 @@ export default function Markets() { q={q} selected={selectedSymbol?.symbol === q.symbol} onClick={() => setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })} + onRemove={activeClass === 'custom' ? () => removeTicker(q.symbol) : undefined} /> )) ) : (