feat: cockpit
This commit is contained in:
@@ -16,6 +16,7 @@ from services.instrument_service import (
|
|||||||
get_narrative,
|
get_narrative,
|
||||||
update_instrument_drivers,
|
update_instrument_drivers,
|
||||||
update_instrument_saxo_link,
|
update_instrument_saxo_link,
|
||||||
|
quick_add_instrument_from_watchlist,
|
||||||
)
|
)
|
||||||
|
|
||||||
class DriverUpdate(BaseModel):
|
class DriverUpdate(BaseModel):
|
||||||
@@ -25,6 +26,10 @@ class DriverUpdate(BaseModel):
|
|||||||
class SaxoLinkBody(BaseModel):
|
class SaxoLinkBody(BaseModel):
|
||||||
saxo_symbol: Optional[str] = None
|
saxo_symbol: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QuickAddBody(BaseModel):
|
||||||
|
ticker: str
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
|
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +41,16 @@ def list_instruments() -> List[Dict[str, Any]]:
|
|||||||
return get_all_instruments()
|
return get_all_instruments()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/quick-add")
|
||||||
|
def quick_add(body: QuickAddBody) -> Dict[str, Any]:
|
||||||
|
"""Bring a Cockpit Watchlist ticker into Instrument Analysis on the fly, copying over
|
||||||
|
its saxo_quote_symbol link automatically — see instrument_service.quick_add_instrument_from_watchlist."""
|
||||||
|
try:
|
||||||
|
return quick_add_instrument_from_watchlist(body.ticker)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instrument_id}/snapshot")
|
@router.get("/{instrument_id}/snapshot")
|
||||||
async def instrument_snapshot(
|
async def instrument_snapshot(
|
||||||
instrument_id: str,
|
instrument_id: str,
|
||||||
|
|||||||
@@ -287,6 +287,13 @@ def init_db():
|
|||||||
drivers_json TEXT,
|
drivers_json TEXT,
|
||||||
updated_at TEXT DEFAULT (datetime('now'))
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
)""",
|
)""",
|
||||||
|
# "Quick add from Watchlist" (Instrument Analysis picker) — name/yf_ticker/category
|
||||||
|
# let a row stand in for a full instruments.json entry when the instrument doesn't
|
||||||
|
# have one yet, instead of only overriding an existing one. See
|
||||||
|
# services.instrument_service._load_configs()'s second merge pass.
|
||||||
|
"ALTER TABLE instrument_overrides ADD COLUMN name TEXT",
|
||||||
|
"ALTER TABLE instrument_overrides ADD COLUMN yf_ticker TEXT",
|
||||||
|
"ALTER TABLE instrument_overrides ADD COLUMN category TEXT",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
c.execute(_sql)
|
c.execute(_sql)
|
||||||
@@ -3406,15 +3413,20 @@ def rename_instrument_watchlist(ticker: str, name: str) -> bool:
|
|||||||
|
|
||||||
def get_instrument_overrides() -> Dict[str, Dict]:
|
def get_instrument_overrides() -> Dict[str, Dict]:
|
||||||
"""All overrides keyed by instrument_id (upper), for merging onto instruments.json
|
"""All overrides keyed by instrument_id (upper), for merging onto instruments.json
|
||||||
at catalog-load time in services/instrument_service.py."""
|
at catalog-load time in services/instrument_service.py. name/yf_ticker/category are
|
||||||
|
only set for "quick add from Watchlist" rows (see set_instrument_override_quick_add) —
|
||||||
|
they let a row stand in for a whole instruments.json entry that doesn't exist yet."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
rows = conn.execute("SELECT instrument_id, saxo_quote_symbol, drivers_json FROM instrument_overrides").fetchall()
|
rows = conn.execute(
|
||||||
|
"SELECT instrument_id, saxo_quote_symbol, drivers_json, name, yf_ticker, category FROM instrument_overrides"
|
||||||
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
out = {}
|
out = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
out[r["instrument_id"]] = {
|
out[r["instrument_id"]] = {
|
||||||
"saxo_quote_symbol": r["saxo_quote_symbol"],
|
"saxo_quote_symbol": r["saxo_quote_symbol"],
|
||||||
"drivers": json.loads(r["drivers_json"]) if r["drivers_json"] else None,
|
"drivers": json.loads(r["drivers_json"]) if r["drivers_json"] else None,
|
||||||
|
"name": r["name"], "yf_ticker": r["yf_ticker"], "category": r["category"],
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -3444,6 +3456,28 @@ def set_instrument_override_drivers(instrument_id: str, drivers: List[Dict]) ->
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def set_instrument_override_quick_add(instrument_id: str, name: str, yf_ticker: str, category: str, saxo_quote_symbol: Optional[str]) -> None:
|
||||||
|
""""Quick add from Watchlist" — instruments_watchlist has a ticker with no
|
||||||
|
instruments.json counterpart, so the user picked it straight from Instrument
|
||||||
|
Analysis's picker instead of hand-authoring a catalog entry. name/yf_ticker/category
|
||||||
|
make this row stand in for a full entry (services.instrument_service._load_configs()
|
||||||
|
synthesizes one with generic chart/drivers defaults when it finds these set on an id
|
||||||
|
that isn't in instruments.json)."""
|
||||||
|
instrument_id = instrument_id.upper()
|
||||||
|
saxo_quote_symbol = (saxo_quote_symbol or "").strip().upper() or None
|
||||||
|
conn = get_conn()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO instrument_overrides (instrument_id, name, yf_ticker, category, saxo_quote_symbol, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(instrument_id) DO UPDATE SET name = excluded.name, yf_ticker = excluded.yf_ticker, "
|
||||||
|
"category = excluded.category, saxo_quote_symbol = COALESCE(instrument_overrides.saxo_quote_symbol, excluded.saxo_quote_symbol), "
|
||||||
|
"updated_at = excluded.updated_at",
|
||||||
|
(instrument_id, name, yf_ticker, category, saxo_quote_symbol),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ── Wavelets — saved simulation/optimization runs ─────────────────────────────
|
# ── Wavelets — saved simulation/optimization runs ─────────────────────────────
|
||||||
|
|
||||||
def save_wavelet_simulation(name: str, form: Dict, results: Optional[List[Dict]] = None,
|
def save_wavelet_simulation(name: str, form: Dict, results: Optional[List[Dict]] = None,
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ _configs: Optional[Dict[str, Any]] = None
|
|||||||
_narrative_cache: Dict[Tuple[str, str], str] = {}
|
_narrative_cache: Dict[Tuple[str, str], str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# "Quick add from Watchlist" — instruments_watchlist's asset_class vocabulary (see
|
||||||
|
# routers/instruments_watchlist.py's _QUOTE_TYPE_TO_ASSET_CLASS) mapped onto instruments.json's
|
||||||
|
# own category vocabulary (CATEGORY_ORDER in frontend/src/pages/InstrumentDashboard.tsx).
|
||||||
|
# Approximate on purpose — this only decides which group heading a quick-added instrument
|
||||||
|
# is filed under in the picker, it doesn't affect chart/Saxo link/drivers.
|
||||||
|
_ASSET_CLASS_TO_CATEGORY = {
|
||||||
|
"forex": "fx", "energy": "energy", "indices": "equity_index",
|
||||||
|
"etfs": "stock", "equities": "stock", "unknown": "stock",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generic chart/driver defaults for a quick-added instrument — instruments.json entries are
|
||||||
|
# hand-curated (custom drivers, ai_context, related_assets...); a quick add has none of that
|
||||||
|
# yet, just enough to render a chart and price. The user can flesh it out later via the
|
||||||
|
# existing Drivers editor (update_instrument_drivers), same as any other instrument.
|
||||||
|
_QUICK_ADD_DEFAULTS = {
|
||||||
|
"chart": {"ma_periods": [20, 50, 200], "bollinger_period": 20, "bollinger_std": 2, "show_volume": True},
|
||||||
|
"drivers": [], "regime_labels": [], "event_keywords": [],
|
||||||
|
"related_assets": [], "correlation_instruments": [], "ai_context": "",
|
||||||
|
"currency": "USD", "description": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_configs() -> None:
|
def _load_configs() -> None:
|
||||||
"""instruments.json is the static, git-tracked catalog (baked into the Docker image —
|
"""instruments.json is the static, git-tracked catalog (baked into the Docker image —
|
||||||
reset to its git contents on every deploy rebuild). Saxo link + drivers are
|
reset to its git contents on every deploy rebuild). Saxo link + drivers are
|
||||||
@@ -40,7 +62,8 @@ def _load_configs() -> None:
|
|||||||
_configs = {inst["id"]: inst for inst in data["instruments"]}
|
_configs = {inst["id"]: inst for inst in data["instruments"]}
|
||||||
|
|
||||||
from services.database import get_instrument_overrides
|
from services.database import get_instrument_overrides
|
||||||
for uid, override in get_instrument_overrides().items():
|
overrides = get_instrument_overrides()
|
||||||
|
for uid, override in overrides.items():
|
||||||
if uid not in _configs:
|
if uid not in _configs:
|
||||||
continue
|
continue
|
||||||
# saxo_quote_symbol: the override row is the sole source of truth once it exists —
|
# saxo_quote_symbol: the override row is the sole source of truth once it exists —
|
||||||
@@ -52,6 +75,22 @@ def _load_configs() -> None:
|
|||||||
if override.get("drivers") is not None:
|
if override.get("drivers") is not None:
|
||||||
_configs[uid]["drivers"] = override["drivers"]
|
_configs[uid]["drivers"] = override["drivers"]
|
||||||
|
|
||||||
|
# "Quick add from Watchlist" — an override row with `name` set but no matching
|
||||||
|
# instruments.json entry stands in for a whole catalog entry (see
|
||||||
|
# set_instrument_override_quick_add / quick_add_instrument_from_watchlist below).
|
||||||
|
for uid, override in overrides.items():
|
||||||
|
if uid in _configs or not override.get("name"):
|
||||||
|
continue
|
||||||
|
_configs[uid] = {
|
||||||
|
"id": uid,
|
||||||
|
"name": override["name"],
|
||||||
|
"yf_ticker": override.get("yf_ticker") or uid,
|
||||||
|
"category": override.get("category") or "stock",
|
||||||
|
"saxo_quote_symbol": override.get("saxo_quote_symbol"),
|
||||||
|
"drivers": override.get("drivers") or [],
|
||||||
|
**{k: v for k, v in _QUICK_ADD_DEFAULTS.items() if k not in ("drivers",)},
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"[instrument_service] Loaded {len(_configs)} instrument configs")
|
logger.info(f"[instrument_service] Loaded {len(_configs)} instrument configs")
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +145,37 @@ def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str])
|
|||||||
logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}")
|
logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}")
|
||||||
|
|
||||||
|
|
||||||
|
def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]:
|
||||||
|
"""Bring a Cockpit watchlist instrument (services.database.instruments_watchlist) into
|
||||||
|
Instrument Analysis without hand-authoring an instruments.json entry — used by the
|
||||||
|
picker's "Depuis la Watchlist" section for tickers that don't already have a catalog
|
||||||
|
entry. Its saxo_quote_symbol (already linked in Config -> Instruments Watchlist) is
|
||||||
|
copied over automatically — no need to re-link it a second time in Instrument Analysis.
|
||||||
|
Idempotent: if instrument_id already resolves (either a real catalog entry or an
|
||||||
|
earlier quick add), returns it unchanged rather than overwriting name/category."""
|
||||||
|
global _configs
|
||||||
|
if _configs is None:
|
||||||
|
_load_configs()
|
||||||
|
|
||||||
|
uid = ticker.strip().upper()
|
||||||
|
if uid in _configs:
|
||||||
|
return {"id": uid, "created": False}
|
||||||
|
|
||||||
|
from services.database import get_instruments_watchlist, set_instrument_override_quick_add
|
||||||
|
row = next((r for r in get_instruments_watchlist() if r["ticker"].upper() == uid), None)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"'{uid}' n'est pas dans la Watchlist du Cockpit")
|
||||||
|
|
||||||
|
category = _ASSET_CLASS_TO_CATEGORY.get(row.get("asset_class") or "unknown", "stock")
|
||||||
|
set_instrument_override_quick_add(
|
||||||
|
uid, name=row.get("name") or uid, yf_ticker=uid, category=category,
|
||||||
|
saxo_quote_symbol=row.get("saxo_quote_symbol"),
|
||||||
|
)
|
||||||
|
_load_configs()
|
||||||
|
logger.info(f"[instrument_service] Quick-added {uid} from Watchlist (category={category})")
|
||||||
|
return {"id": uid, "created": True}
|
||||||
|
|
||||||
|
|
||||||
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
||||||
|
|||||||
@@ -219,6 +219,16 @@ export const useSetInstrumentSaxoLink = () =>
|
|||||||
api.put(`/instruments/${encodeURIComponent(instrumentId)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data),
|
api.put(`/instruments/${encodeURIComponent(instrumentId)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Brings a Cockpit Watchlist ticker into Instrument Analysis without hand-authoring an
|
||||||
|
// instruments.json entry — its saxo_quote_symbol (already linked in Config -> Instruments
|
||||||
|
// Watchlist) is copied over automatically. Idempotent: a ticker that already resolves
|
||||||
|
// (real catalog entry or an earlier quick add) is returned unchanged.
|
||||||
|
export const useQuickAddInstrument = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (ticker: string) =>
|
||||||
|
api.post('/instruments/quick-add', { ticker }).then(r => r.data as { id: string; created: boolean }),
|
||||||
|
})
|
||||||
|
|
||||||
// Free-form display name — no longer tied to yfinance's long_name lookup, since an
|
// Free-form display name — no longer tied to yfinance's long_name lookup, since an
|
||||||
// instrument can now be entirely Saxo-sourced.
|
// instrument can now be entirely Saxo-sourced.
|
||||||
export const useRenameWatchlistInstrument = () => {
|
export const useRenameWatchlistInstrument = () => {
|
||||||
|
|||||||
@@ -482,15 +482,6 @@ export default function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{catalogId && (
|
|
||||||
<Link
|
|
||||||
to={`/instruments/${encodeURIComponent(catalogId)}`}
|
|
||||||
className="text-slate-600 hover:text-blue-400 shrink-0 transition-colors"
|
|
||||||
title={`Open ${it.name || it.ticker} in Instrument Analysis`}
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import clsx from 'clsx'
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||||
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
|
||||||
import SaxoLinkPicker from '../components/SaxoLinkPicker'
|
import SaxoLinkPicker from '../components/SaxoLinkPicker'
|
||||||
import { useSetInstrumentSaxoLink } from '../hooks/useApi'
|
import { useSetInstrumentSaxoLink, useInstrumentsWatchlist, useQuickAddInstrument } from '../hooks/useApi'
|
||||||
import { fmtPrice } from '../lib/format'
|
import { fmtPrice } from '../lib/format'
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
const api = axios.create({ baseURL: '/api' })
|
||||||
@@ -197,6 +197,18 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
|
fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Instrument Analysis's catalog keys FX pairs with yfinance's "=X" suffix (EURUSD=X),
|
||||||
|
// while the Cockpit watchlist stores the plain ticker (EURUSD) — try both forms before
|
||||||
|
// concluding a watchlist instrument has no catalog entry yet (same logic as Dashboard.tsx's
|
||||||
|
// resolveCatalogId, duplicated here since the two pages don't share a components module).
|
||||||
|
function resolveWatchlistCatalogId(ticker: string, catalog: { id: string }[]): string | undefined {
|
||||||
|
const upper = ticker.toUpperCase()
|
||||||
|
const ids = new Set(catalog.map(i => i.id.toUpperCase()))
|
||||||
|
if (ids.has(upper)) return upper
|
||||||
|
if (ids.has(`${upper}=X`)) return `${upper}=X`
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
function regimeColor(label: string): string {
|
function regimeColor(label: string): string {
|
||||||
const l = label.toLowerCase()
|
const l = label.toLowerCase()
|
||||||
if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
|
if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
|
||||||
@@ -1424,6 +1436,9 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
|
const [chartStyle, setChartStyle] = useState<'candles' | 'line'>('candles')
|
||||||
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
|
const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
|
||||||
const setSaxoLink = useSetInstrumentSaxoLink()
|
const setSaxoLink = useSetInstrumentSaxoLink()
|
||||||
|
const { data: watchlistItems } = useInstrumentsWatchlist()
|
||||||
|
const quickAdd = useQuickAddInstrument()
|
||||||
|
const [addingTicker, setAddingTicker] = useState<string | null>(null)
|
||||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
const [snapshot, setSnapshot] = useState<Snapshot | null>(null)
|
||||||
const [narrative, setNarrative] = useState('')
|
const [narrative, setNarrative] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -1635,6 +1650,12 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
items: instruments.filter(i => i.category === cat),
|
items: instruments.filter(i => i.category === cat),
|
||||||
})).filter(g => g.items.length > 0)
|
})).filter(g => g.items.length > 0)
|
||||||
|
|
||||||
|
// Cockpit Watchlist tickers that don't already resolve to a catalog entry — offered as
|
||||||
|
// "quick add" picks (see resolveWatchlistCatalogId below for the resolution itself).
|
||||||
|
// Excludes anything already resolvable so picking it never creates a duplicate id.
|
||||||
|
const watchlistCandidates = ((watchlistItems as any[]) ?? [])
|
||||||
|
.filter(w => !resolveWatchlistCatalogId(w.ticker, instruments))
|
||||||
|
|
||||||
const selected = instruments.find(i => i.id === instrumentId)
|
const selected = instruments.find(i => i.id === instrumentId)
|
||||||
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
const isLastDate = effectiveDate === sortedDates[sortedDates.length - 1]
|
||||||
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
const dateLabel = effectiveDate ? fmtDateFR(effectiveDate) : '—'
|
||||||
@@ -1692,6 +1713,32 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Picking a Watchlist instrument that has no catalog entry yet: quick-add it (copies
|
||||||
|
// its saxo_quote_symbol over automatically, see services.instrument_service.
|
||||||
|
// quick_add_instrument_from_watchlist) then navigate straight there. Already-resolvable
|
||||||
|
// tickers (e.g. EURUSD -> EURUSD=X) skip the round-trip and navigate directly.
|
||||||
|
const handleSelectWatchlist = (ticker: string) => {
|
||||||
|
const existing = resolveWatchlistCatalogId(ticker, instruments)
|
||||||
|
if (existing) {
|
||||||
|
navigate(`/instruments/${encodeURIComponent(existing)}`)
|
||||||
|
setSelectorOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAddingTicker(ticker)
|
||||||
|
quickAdd.mutate(ticker, {
|
||||||
|
onSuccess: ({ id }) => {
|
||||||
|
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
||||||
|
navigate(`/instruments/${encodeURIComponent(id)}`)
|
||||||
|
setSelectorOpen(false)
|
||||||
|
setAddingTicker(null)
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
console.error('Quick-add from watchlist failed', e)
|
||||||
|
setAddingTicker(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const waveletChartData = useMemo(() => {
|
const waveletChartData = useMemo(() => {
|
||||||
if (!waveletData) return []
|
if (!waveletData) return []
|
||||||
const { dates, original, bands, mean } = waveletData
|
const { dates, original, bands, mean } = waveletData
|
||||||
@@ -1917,6 +1964,27 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{watchlistCandidates.length > 0 && (
|
||||||
|
<div className="mt-1 pt-2 border-t border-slate-700/40">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">Depuis la Watchlist</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{watchlistCandidates.map((w: any) => (
|
||||||
|
<button key={w.ticker}
|
||||||
|
onClick={() => handleSelectWatchlist(w.ticker)}
|
||||||
|
disabled={addingTicker === w.ticker}
|
||||||
|
className="text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors text-slate-400 hover:bg-dark-700 hover:text-white disabled:opacity-50"
|
||||||
|
title={w.saxo_quote_symbol ? `Lien Saxo auto: ${w.saxo_quote_symbol}` : 'Pas encore lié à Saxo (Config → Instruments Watchlist)'}
|
||||||
|
>
|
||||||
|
<div className="font-semibold flex items-center gap-1">
|
||||||
|
{w.ticker}
|
||||||
|
{w.saxo_quote_symbol && <Link2 className="w-2.5 h-2.5 text-emerald-500 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-500 truncate">{addingTicker === w.ticker ? 'Ajout…' : (w.name || w.ticker)}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user