import { useState, useEffect, useMemo, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
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, Plus, X } from 'lucide-react'
type ExtAssetClass = AssetClass | 'custom'
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: '๐ฑ' },
]
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 (
{q.name || q.symbol}
{q.symbol}
{pos ? (
) : (
)}
{onRemove && (
)}
{q.price.toFixed(q.price > 100 ? 2 : 4)}
{pos ? '+' : ''}{q.change_pct.toFixed(2)}%
{q.iv !== undefined && (
IV: {(q.iv * 100).toFixed(1)}%
)}
)
}
const PERIOD_OPTIONS = [
{ label: '5D', value: '5d' },
{ label: '1M', value: '1mo' },
{ label: '3M', value: '3mo' },
{ label: '6M', value: '6mo' },
{ label: '1Y', value: '1y' },
{ label: '2Y', value: '2y' },
]
function PriceChart({ symbol, name }: { symbol: string; name: string }) {
const [period, setPeriod] = useState('3mo')
const { data: hist, isLoading } = useHistory(symbol, period)
const chartData = hist?.map(h => ({
date: h.date.slice(0, 10),
close: h.close,
open: h.open,
high: h.high,
low: h.low,
})) ?? []
const first = chartData[0]?.close ?? 0
const last = chartData[chartData.length - 1]?.close ?? 0
const isUp = last >= first
const chg = first > 0 ? ((last - first) / first * 100).toFixed(2) : '0'
return (
{isUp ? '+' : ''}{chg}%
{PERIOD_OPTIONS.map(p => (
))}
{isLoading ? (
) : chartData.length > 0 ? (
v.slice(5)} interval="preserveStartEnd" />
v > 1000 ? `${(v/1000).toFixed(1)}k` : v.toFixed(2)} />
[v.toFixed(4), 'Price']}
/>
) : (
No data available
)}
)
}
export default function Markets() {
const { data: allQuotes, isLoading, refetch } = useAllQuotes()
const [searchParams, setSearchParams] = useSearchParams()
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(() => {
if (!initialSymbol.current || !allQuotes) return
const sym = initialSymbol.current
initialSymbol.current = null
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 ExtAssetClass)
setSelectedSymbol({ symbol: match.symbol, name: match.name || match.symbol })
break
}
}
setSearchParams({}, { replace: true })
}, [allQuotes, setSearchParams])
const allQuotesFlat = useMemo(() => {
if (!allQuotes) return []
return Object.entries(allQuotes).flatMap(([cls, quotes]) =>
(quotes as Quote[]).filter(q => q.price).map(q => ({ ...q, assetClass: cls as AssetClass }))
)
}, [allQuotes])
const searchResults = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
if (!q) return []
return allQuotesFlat
.filter(quote => quote.symbol.toLowerCase().includes(q) || (quote.name ?? '').toLowerCase().includes(q))
.slice(0, 8)
}, [searchQuery, allQuotesFlat])
const currentQuotes = allQuotes?.[activeClass] ?? []
return (
Markets & Prices
Real-time data ยท Implied volatility ยท Options opportunities
{/* Asset class tabs + ticker search */}
{ASSET_CLASSES.map(({ key, label, emoji }) => (
))}
{(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}}
) : (
)}
setSearchQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') setSearchQuery('') }}
placeholder="Search ticker..."
className="bg-dark-700 border border-slate-700 rounded pl-8 pr-3 py-1.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-blue-500 w-52"
/>
{searchResults.length > 0 && (
{searchResults.map(q => (
))}
)}
{/* Quote grid */}
{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 =>
)
) : currentQuotes.length > 0 ? (
currentQuotes.map(q => (
setSelectedSymbol({ symbol: q.symbol, name: q.name || q.symbol })}
onRemove={activeClass === 'custom' ? () => removeTicker(q.symbol) : undefined}
/>
))
) : (
Start the backend to load prices
)}
{/* Chart */}
{selectedSymbol ? (
) : (
Select an instrument
to view the price chart
)}
{/* Market overview table */}
Overview โ All Markets
| Instrument |
Class |
Price |
1d Chg |
IV Vol (est.) |
Signal |
{allQuotes && Object.entries(allQuotes).flatMap(([cls, quotes]) =>
quotes.map(q => q.price && (
|
{q.name || q.symbol}
{q.symbol}
|
{cls} |
{q.price.toFixed(q.price > 100 ? 2 : 4)}
|
= 0 ? 'positive' : 'negative')}>
{q.change_pct >= 0 ? '+' : ''}{q.change_pct.toFixed(2)}%
|
{q.iv ? `${(q.iv * 100).toFixed(1)}%` : 'โ'}
|
{q.change_pct > 1.5 ? โฒ Bullish
: q.change_pct < -1.5 ? โผ Bearish
: โ Neutral}
|
))
)}
)
}