feat: instrument analysis
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -15,11 +15,16 @@ from services.instrument_service import (
|
|||||||
get_snapshot,
|
get_snapshot,
|
||||||
get_narrative,
|
get_narrative,
|
||||||
update_instrument_drivers,
|
update_instrument_drivers,
|
||||||
|
update_instrument_saxo_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
class DriverUpdate(BaseModel):
|
class DriverUpdate(BaseModel):
|
||||||
drivers: List[Dict[str, Any]]
|
drivers: List[Dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class SaxoLinkBody(BaseModel):
|
||||||
|
saxo_symbol: Optional[str] = None
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
|
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +95,25 @@ def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
|
|||||||
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
|
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{instrument_id}/saxo-link")
|
||||||
|
def set_saxo_link(instrument_id: str, body: SaxoLinkBody) -> Dict[str, Any]:
|
||||||
|
"""Link this instrument to the Saxo symbol used to price it (chart, indicators, regime,
|
||||||
|
wavelets) instead of yfinance — or pass null to unlink. Mirrors
|
||||||
|
routers/instruments_watchlist.py's /{ticker}/saxo-quote-link, but persisted in
|
||||||
|
instruments.json (this catalog's own store) rather than the instruments_watchlist table."""
|
||||||
|
config = get_instrument(instrument_id)
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
|
||||||
|
|
||||||
|
saxo_symbol = (body.saxo_symbol or "").strip().upper() or None
|
||||||
|
try:
|
||||||
|
update_instrument_saxo_link(instrument_id, saxo_symbol)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
return {"instrument_id": instrument_id.upper(), "saxo_quote_symbol": saxo_symbol}
|
||||||
|
|
||||||
|
|
||||||
# ── Instrument mult (pips → price conversion) ─────────────────────────────────
|
# ── Instrument mult (pips → price conversion) ─────────────────────────────────
|
||||||
|
|
||||||
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}
|
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}
|
||||||
|
|||||||
@@ -23,7 +23,26 @@ _PERIOD_TO_DAYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None):
|
def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Optional[str] = None, end: Optional[str] = None, saxo_symbol: Optional[str] = None):
|
||||||
|
"""Saxo-first when saxo_symbol is given (resolved via the watchlist/Instrument Analysis
|
||||||
|
Saxo link), yfinance otherwise or as a silent fallback on any Saxo failure. Wavelets only
|
||||||
|
ever consume the close series, so unlike instrument_service's snapshot fetch this doesn't
|
||||||
|
need full OHLCV."""
|
||||||
|
if saxo_symbol and start is None and end is None:
|
||||||
|
# Saxo's Chart API is day-count-from-now only (no explicit date range), so a custom
|
||||||
|
# start_date/end_date request can't be served from Saxo — falls through to yfinance.
|
||||||
|
try:
|
||||||
|
from services.database import get_saxo_catalog_by_symbol
|
||||||
|
from services.saxo_client import get_price_history
|
||||||
|
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
||||||
|
asset_type = entry["asset_type"] if entry else "FxSpot"
|
||||||
|
days = _PERIOD_TO_DAYS.get(period, 365)
|
||||||
|
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
||||||
|
return [b["close"] for b in bars], [b["date"] for b in bars]
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(f"[wavelet] Saxo fetch failed for '{saxo_symbol}', falling back to yfinance: {e}")
|
||||||
|
|
||||||
from services.data_fetcher import get_historical
|
from services.data_fetcher import get_historical
|
||||||
hist = get_historical(symbol, period=period, interval=interval, start=start, end=end)
|
hist = get_historical(symbol, period=period, interval=interval, start=start, end=end)
|
||||||
values = [h["close"] for h in hist]
|
values = [h["close"] for h in hist]
|
||||||
@@ -34,7 +53,7 @@ def _fetch_history(symbol: str, period: str, interval: str = "1d", start: Option
|
|||||||
def _fetch_padded_history(
|
def _fetch_padded_history(
|
||||||
symbol: str, lookback: int, period: str = "1y",
|
symbol: str, lookback: int, period: str = "1y",
|
||||||
start_date: Optional[str] = None, end_date: Optional[str] = None,
|
start_date: Optional[str] = None, end_date: Optional[str] = None,
|
||||||
future_padding_days: int = 0,
|
future_padding_days: int = 0, saxo_symbol: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Fetch enough history to cover the requested causal-output range plus a
|
"""Fetch enough history to cover the requested causal-output range plus a
|
||||||
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).
|
`lookback`-sized warmup window before it (mirrors main.py's fetch_start padding).
|
||||||
@@ -60,7 +79,7 @@ def _fetch_padded_history(
|
|||||||
if start_date:
|
if start_date:
|
||||||
fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat()
|
fetch_start = (date.fromisoformat(start_date) - timedelta(days=pad_days)).isoformat()
|
||||||
fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date
|
fetch_end = (date.fromisoformat(end_date) + timedelta(days=future_padding_days)).isoformat() if end_date and future_padding_days else end_date
|
||||||
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end)
|
values, dates = _fetch_history(symbol, period, start=fetch_start, end=fetch_end, saxo_symbol=saxo_symbol)
|
||||||
return values, dates, start_date
|
return values, dates, start_date
|
||||||
|
|
||||||
out_days = _PERIOD_TO_DAYS.get(period, 365)
|
out_days = _PERIOD_TO_DAYS.get(period, 365)
|
||||||
@@ -69,7 +88,7 @@ def _fetch_padded_history(
|
|||||||
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
|
(p for p, d in sorted(_PERIOD_TO_DAYS.items(), key=lambda kv: kv[1]) if d >= total_days),
|
||||||
"10y",
|
"10y",
|
||||||
)
|
)
|
||||||
values, dates = _fetch_history(symbol, fetch_period)
|
values, dates = _fetch_history(symbol, fetch_period, saxo_symbol=saxo_symbol)
|
||||||
cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
|
cutoff = (datetime.utcnow() - timedelta(days=out_days)).date().isoformat()
|
||||||
return values, dates, cutoff
|
return values, dates, cutoff
|
||||||
|
|
||||||
@@ -84,10 +103,11 @@ def wavelet_analyze(
|
|||||||
method: str = Query("cwt", description="cwt (default) or ssq"),
|
method: str = Query("cwt", description="cwt (default) or ssq"),
|
||||||
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"),
|
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period` with an explicit custom range"),
|
||||||
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
||||||
|
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
|
||||||
):
|
):
|
||||||
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
|
from services.wavelet_engine import windowed_band_decompose, band_decompose_ssq
|
||||||
|
|
||||||
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date)
|
values, dates = _fetch_history(symbol, period, start=start_date, end=end_date, saxo_symbol=saxo_symbol)
|
||||||
if len(values) < 32:
|
if len(values) < 32:
|
||||||
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
|
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
|
||||||
|
|
||||||
@@ -112,13 +132,14 @@ def wavelet_rolling(
|
|||||||
method: str = Query("cwt", description="cwt (default) or ssq"),
|
method: str = Query("cwt", description="cwt (default) or ssq"),
|
||||||
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
|
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
|
||||||
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
||||||
|
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
|
||||||
):
|
):
|
||||||
"""Walk-forward version of /analyze: band values are computed day by day from a
|
"""Walk-forward version of /analyze: band values are computed day by day from a
|
||||||
trailing `lookback`-point window only, so a trade simulation built on this never
|
trailing `lookback`-point window only, so a trade simulation built on this never
|
||||||
sees data past its own decision date."""
|
sees data past its own decision date."""
|
||||||
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
|
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq
|
||||||
|
|
||||||
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date)
|
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, saxo_symbol=saxo_symbol)
|
||||||
if len(values) < lookback + 32:
|
if len(values) < lookback + 32:
|
||||||
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
|
raise HTTPException(400, "Historique insuffisant pour une analyse ondelette (32 points minimum).")
|
||||||
|
|
||||||
@@ -158,6 +179,7 @@ def wavelet_reliability_endpoint(
|
|||||||
max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"),
|
max_future_padding: int = Query(60, ge=10, le=180, description="extra real days fetched beyond the requested range, to cover the slowest band's own (data-driven) confirm horizon"),
|
||||||
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
|
start_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD) — overrides `period`; the causal output starts here"),
|
||||||
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
end_date: Optional[str] = Query(None, description="ISO date (YYYY-MM-DD), only used alongside start_date; omit for 'through today'"),
|
||||||
|
saxo_symbol: Optional[str] = Query(None, description="Saxo quote symbol — Saxo-first, falls back to `symbol` (yfinance) on any failure"),
|
||||||
):
|
):
|
||||||
"""For every reversal a live (causal, walk-forward) decomposition would have flagged,
|
"""For every reversal a live (causal, walk-forward) decomposition would have flagged,
|
||||||
checks whether redoing the decomposition later still shows the same reversal — a
|
checks whether redoing the decomposition later still shows the same reversal — a
|
||||||
@@ -169,7 +191,7 @@ def wavelet_reliability_endpoint(
|
|||||||
# The horizon is now computed per band inside wavelet_reliability (from each band's own
|
# The horizon is now computed per band inside wavelet_reliability (from each band's own
|
||||||
# measured cycle length), so we don't know it in advance here — pad generously enough to
|
# measured cycle length), so we don't know it in advance here — pad generously enough to
|
||||||
# cover even a slow band's cycle instead.
|
# cover even a slow band's cycle instead.
|
||||||
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding)
|
values, dates, cutoff = _fetch_padded_history(symbol, lookback, period, start_date, end_date, future_padding_days=max_future_padding, saxo_symbol=saxo_symbol)
|
||||||
if len(values) < lookback + max_future_padding + 32:
|
if len(values) < lookback + max_future_padding + 32:
|
||||||
raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).")
|
raise HTTPException(400, "Historique insuffisant pour un test de fiabilité (32 points minimum au-delà de la fenêtre + marge).")
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ def update_instrument_drivers(instrument_id: str, drivers: List[Dict]) -> None:
|
|||||||
logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)")
|
logger.info(f"[instrument_service] Updated drivers for {uid} ({len(drivers)} drivers)")
|
||||||
|
|
||||||
|
|
||||||
|
def update_instrument_saxo_link(instrument_id: str, saxo_symbol: Optional[str]) -> None:
|
||||||
|
"""Persist the Saxo quote-symbol link to instruments.json and refresh in-memory config.
|
||||||
|
saxo_symbol=None clears the link (falls back to yfinance)."""
|
||||||
|
global _configs
|
||||||
|
if _configs is None:
|
||||||
|
_load_configs()
|
||||||
|
|
||||||
|
uid = instrument_id.upper()
|
||||||
|
if uid not in _configs:
|
||||||
|
raise ValueError(f"Instrument {uid} not found")
|
||||||
|
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
|
||||||
|
for inst in raw["instruments"]:
|
||||||
|
if inst["id"] == uid:
|
||||||
|
inst["saxo_quote_symbol"] = saxo_symbol
|
||||||
|
break
|
||||||
|
|
||||||
|
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(raw, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
_configs[uid]["saxo_quote_symbol"] = saxo_symbol
|
||||||
|
logger.info(f"[instrument_service] Updated Saxo link for {uid}: {saxo_symbol}")
|
||||||
|
|
||||||
|
|
||||||
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
||||||
@@ -653,6 +679,46 @@ def _get_relevant_events(
|
|||||||
|
|
||||||
# ── Main snapshot ──────────────────────────────────────────────────────────────
|
# ── Main snapshot ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Approximate calendar-day span of each yfinance-style period string — used to size the
|
||||||
|
# Saxo `days` fetch window (Saxo's Chart API is day-count based, not period-string based).
|
||||||
|
# Same values as routers/wavelet.py's _PERIOD_TO_DAYS; duplicated locally since it's a
|
||||||
|
# tiny, stable lookup table not worth sharing across modules.
|
||||||
|
_PERIOD_TO_DAYS = {
|
||||||
|
"5d": 5, "1mo": 30, "3mo": 90, "6mo": 182,
|
||||||
|
"1y": 365, "2y": 730, "5y": 1825, "10y": 3650, "max": 3650,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ohlcv(config: Dict[str, Any], instrument_id: str, period: str, interval: str) -> Tuple[List[Dict], str]:
|
||||||
|
"""Fetch OHLCV records for the snapshot — Saxo-first when the instrument has a
|
||||||
|
saxo_quote_symbol linked, yfinance otherwise (or as a silent fallback on any Saxo
|
||||||
|
failure). Returns (records, source)."""
|
||||||
|
saxo_symbol = config.get("saxo_quote_symbol")
|
||||||
|
if saxo_symbol:
|
||||||
|
try:
|
||||||
|
from services.database import get_saxo_catalog_by_symbol
|
||||||
|
from services.saxo_client import get_price_history
|
||||||
|
entry = get_saxo_catalog_by_symbol(saxo_symbol)
|
||||||
|
asset_type = entry["asset_type"] if entry else "FxSpot"
|
||||||
|
days = _PERIOD_TO_DAYS.get(period, 365)
|
||||||
|
bars = get_price_history(saxo_symbol, asset_type, days=days)
|
||||||
|
records = [{"date": b["date"], "open": b.get("open"), "high": b.get("high"),
|
||||||
|
"low": b.get("low"), "close": b.get("close"), "volume": b.get("volume")}
|
||||||
|
for b in bars]
|
||||||
|
return records, "saxo"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[instrument_service] Saxo fetch failed for {instrument_id} ({saxo_symbol}), falling back to yfinance: {e}")
|
||||||
|
|
||||||
|
yf_ticker = config.get("yf_ticker", instrument_id)
|
||||||
|
try:
|
||||||
|
from services.data_fetcher import get_historical
|
||||||
|
records = get_historical(yf_ticker, period=period, interval=interval)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
||||||
|
records = []
|
||||||
|
return records, "yfinance"
|
||||||
|
|
||||||
|
|
||||||
async def get_snapshot(
|
async def get_snapshot(
|
||||||
instrument_id: str,
|
instrument_id: str,
|
||||||
period: str = "1y",
|
period: str = "1y",
|
||||||
@@ -666,16 +732,7 @@ async def get_snapshot(
|
|||||||
if not config:
|
if not config:
|
||||||
return {"error": f"Unknown instrument: {instrument_id}"}
|
return {"error": f"Unknown instrument: {instrument_id}"}
|
||||||
|
|
||||||
yf_ticker = config.get("yf_ticker", instrument_id)
|
records, source = _fetch_ohlcv(config, instrument_id, period, interval)
|
||||||
|
|
||||||
# Fetch OHLCV
|
|
||||||
try:
|
|
||||||
from services.data_fetcher import get_historical
|
|
||||||
records = get_historical(yf_ticker, period=period, interval=interval)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[instrument_service] Data fetch failed for {instrument_id}: {e}")
|
|
||||||
records = []
|
|
||||||
|
|
||||||
df = _ohlcv_to_df(records)
|
df = _ohlcv_to_df(records)
|
||||||
|
|
||||||
# Compute everything
|
# Compute everything
|
||||||
@@ -752,6 +809,7 @@ async def get_snapshot(
|
|||||||
"change_pct": change_pct,
|
"change_pct": change_pct,
|
||||||
"change_abs": change_abs,
|
"change_abs": change_abs,
|
||||||
"period": period,
|
"period": period,
|
||||||
|
"source": source,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -249,7 +249,9 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
|
|||||||
Callers should treat any failure here (entitlement gap, still-wrong field names for
|
Callers should treat any failure here (entitlement gap, still-wrong field names for
|
||||||
some other asset type, etc.) as routine and fall back to another source, not surface
|
some other asset type, etc.) as routine and fall back to another source, not surface
|
||||||
it as a hard error.
|
it as a hard error.
|
||||||
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float}, ...].
|
Returns oldest-first [{"date": "YYYY-MM-DD", "close": float, "open": float|None,
|
||||||
|
"high": float|None, "low": float|None, "volume": float|None}, ...] — open/high/low/volume
|
||||||
|
are None when the raw bar doesn't carry them (e.g. FX Spot has no traded volume).
|
||||||
"""
|
"""
|
||||||
instrument = resolve_instrument(symbol, asset_types=asset_type)
|
instrument = resolve_instrument(symbol, asset_types=asset_type)
|
||||||
data = _get("/chart/v3/charts", {
|
data = _get("/chart/v3/charts", {
|
||||||
@@ -271,7 +273,14 @@ def get_price_history(symbol: str, asset_type: str = "FxSpot", days: int = 90) -
|
|||||||
close = (bar["CloseBid"] + bar["CloseAsk"]) / 2
|
close = (bar["CloseBid"] + bar["CloseAsk"]) / 2
|
||||||
if close is None or not time_str:
|
if close is None or not time_str:
|
||||||
continue
|
continue
|
||||||
out.append({"date": str(time_str)[:10], "close": float(close)})
|
out.append({
|
||||||
|
"date": str(time_str)[:10],
|
||||||
|
"close": float(close),
|
||||||
|
"open": float(bar["Open"]) if bar.get("Open") is not None else None,
|
||||||
|
"high": float(bar["High"]) if bar.get("High") is not None else None,
|
||||||
|
"low": float(bar["Low"]) if bar.get("Low") is not None else None,
|
||||||
|
"volume": float(bar["Volume"]) if bar.get("Volume") is not None else None,
|
||||||
|
})
|
||||||
if not out:
|
if not out:
|
||||||
raise ValueError(f"Chart data for '{symbol}' had no usable Close/CloseMid/CloseBid+CloseAsk/Time fields — raw bar keys: {list(bars[0].keys()) if bars else []}")
|
raise ValueError(f"Chart data for '{symbol}' had no usable Close/CloseMid/CloseBid+CloseAsk/Time fields — raw bar keys: {list(bars[0].keys()) if bars else []}")
|
||||||
return out
|
return out
|
||||||
|
|||||||
106
frontend/src/components/SaxoLinkPicker.tsx
Normal file
106
frontend/src/components/SaxoLinkPicker.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link2, Unlink } from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useSaxoCatalog } from '../hooks/useApi'
|
||||||
|
|
||||||
|
// Saxo has no shared naming convention with yfinance — its catalog search matches on
|
||||||
|
// Saxo's own instrument description text ("Brent Crude"), not the yfinance ticker code
|
||||||
|
// ("BZ=F"). This maps the common yfinance futures-root/index tickers to the plain-English
|
||||||
|
// name worth searching for, so opening a picker pre-fills something useful instead of an
|
||||||
|
// empty box the user has to guess into. Not exhaustive — anything not listed here (most
|
||||||
|
// FX pairs, where the Saxo symbol IS the yfinance root, e.g. "EURUSD=X" -> "EURUSD") falls
|
||||||
|
// back to a best-effort strip of yfinance's own suffix/prefix decoration.
|
||||||
|
const YFINANCE_SEARCH_HINTS: Record<string, string> = {
|
||||||
|
'BZ=F': 'Brent', 'CL=F': 'WTI Crude', 'NG=F': 'Natural Gas',
|
||||||
|
'GC=F': 'Gold', 'SI=F': 'Silver', 'HG=F': 'Copper', 'PL=F': 'Platinum',
|
||||||
|
'^GSPC': 'S&P 500', '^NDX': 'Nasdaq 100', '^DJI': 'Dow Jones', '^RUT': 'Russell 2000',
|
||||||
|
'^VIX': 'VIX', 'IEF': 'Treasury',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function guessSaxoSearchHint(ticker: string): string {
|
||||||
|
if (YFINANCE_SEARCH_HINTS[ticker]) return YFINANCE_SEARCH_HINTS[ticker]
|
||||||
|
return ticker.replace(/=F$|=X$|^\^/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SAXO_LINK_KIND_META = {
|
||||||
|
option: {
|
||||||
|
label: 'Option', placeholder: 'Search Saxo option symbol…',
|
||||||
|
activeCls: 'text-emerald-400 hover:text-emerald-300 bg-emerald-900/20 border-emerald-700/30',
|
||||||
|
assetTypes: 'FuturesOption,FxVanillaOption,StockOption,StockIndexOption',
|
||||||
|
},
|
||||||
|
quote: {
|
||||||
|
label: 'Quote', placeholder: 'Search Saxo spot/futures symbol…',
|
||||||
|
activeCls: 'text-sky-400 hover:text-sky-300 bg-sky-900/20 border-sky-700/30',
|
||||||
|
assetTypes: 'ContractFutures,CfdOnFutures,FxSpot,StockIndex',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SaxoLinkKind = keyof typeof SAXO_LINK_KIND_META
|
||||||
|
|
||||||
|
// Generic Saxo symbol linker — ticker/saxoSymbol are display data, onSave/isPending are
|
||||||
|
// injected by the caller so this can drive any backing store (instruments_watchlist's
|
||||||
|
// saxo_option_symbol/saxo_quote_symbol columns via Config.tsx, or instruments.json's
|
||||||
|
// saxo_quote_symbol field via InstrumentDashboard.tsx) without knowing which one it is.
|
||||||
|
export default function SaxoLinkPicker({ ticker, kind, saxoSymbol, onSave, isPending }: {
|
||||||
|
ticker: string
|
||||||
|
kind: SaxoLinkKind
|
||||||
|
saxoSymbol: string | null
|
||||||
|
onSave: (symbol: string | null) => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [value, setValue] = useState(saxoSymbol ?? '')
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false)
|
||||||
|
const meta = SAXO_LINK_KIND_META[kind]
|
||||||
|
const { data: catalogMatches } = useSaxoCatalog(meta.assetTypes, value.length >= 2 ? value : undefined)
|
||||||
|
|
||||||
|
const save = (sym?: string) => {
|
||||||
|
const resolved = (sym ?? value).trim().toUpperCase() || null
|
||||||
|
onSave(resolved)
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return saxoSymbol ? (
|
||||||
|
<button onClick={() => { setEditing(true); setValue(saxoSymbol) }} className={clsx('flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border', meta.activeCls)}>
|
||||||
|
<Link2 className="w-2.5 h-2.5" /> {meta.label}: {saxoSymbol}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => { setEditing(true); setValue(guessSaxoSearchHint(ticker)); setShowDropdown(true) }} className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 border border-slate-700/40 px-1.5 py-0.5 rounded">
|
||||||
|
<Unlink className="w-2.5 h-2.5" /> {meta.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
onChange={e => { setValue(e.target.value); setShowDropdown(true) }}
|
||||||
|
onFocus={() => setShowDropdown(true)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
|
||||||
|
onBlur={() => save()}
|
||||||
|
placeholder={meta.placeholder}
|
||||||
|
className="bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-[9px] text-white w-28 focus:outline-none focus:border-blue-500/50"
|
||||||
|
/>
|
||||||
|
{isPending && <span className="text-[9px] text-slate-600 ml-1">…</span>}
|
||||||
|
{showDropdown && (catalogMatches ?? []).length > 0 && (
|
||||||
|
<div className="absolute z-20 top-full left-0 mt-1 w-72 max-h-60 overflow-y-auto bg-dark-800 border border-slate-700 rounded shadow-xl">
|
||||||
|
{(catalogMatches ?? []).map((c: any) => (
|
||||||
|
<button
|
||||||
|
key={c.symbol}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => e.preventDefault()}
|
||||||
|
onClick={() => { setValue(c.symbol); setShowDropdown(false); save(c.symbol) }}
|
||||||
|
className="flex items-center justify-between gap-3 w-full text-left px-2.5 py-1.5 text-[10px] hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-mono font-bold text-white shrink-0">{c.symbol}</span>
|
||||||
|
<span className="text-slate-500 truncate">{c.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -200,6 +200,17 @@ export const useSetWatchlistSaxoQuoteLink = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Links an Instrument Analysis instrument (backend/config/instruments.json — a separate
|
||||||
|
// catalog from instruments_watchlist above) to a Saxo quote symbol, so its chart/indicators/
|
||||||
|
// wavelets are Saxo-sourced instead of yfinance. InstrumentDashboard.tsx doesn't use React
|
||||||
|
// Query for its own data (plain axios + useState), so this mutation carries no
|
||||||
|
// invalidateQueries — the caller re-fetches the instrument list/snapshot itself on success.
|
||||||
|
export const useSetInstrumentSaxoLink = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: ({ instrumentId, saxoSymbol }: { instrumentId: string; saxoSymbol: string | null }) =>
|
||||||
|
api.put(`/instruments/${encodeURIComponent(instrumentId)}/saxo-link`, { saxo_symbol: saxoSymbol }).then(r => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
// 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 = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, type CycleStepDef } from '../hooks/useApi'
|
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, type CycleStepDef } from '../hooks/useApi'
|
||||||
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react'
|
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import SaxoLinkPicker from '../components/SaxoLinkPicker'
|
||||||
|
|
||||||
const API = ''
|
const API = ''
|
||||||
|
|
||||||
@@ -338,101 +339,6 @@ function RiskProfilesCard() {
|
|||||||
// scheduler's watchlist (Config -> Saxo below) so it gets snapshotted.
|
// scheduler's watchlist (Config -> Saxo below) so it gets snapshotted.
|
||||||
// 'quote' — which Saxo spot/futures symbol prices this instrument in the Cockpit,
|
// 'quote' — which Saxo spot/futures symbol prices this instrument in the Cockpit,
|
||||||
// replacing yfinance's unadjusted continuous-futures tickers.
|
// replacing yfinance's unadjusted continuous-futures tickers.
|
||||||
// Asset-type filters matching backend services.saxo_client.OPTION_ASSET_TYPES /
|
|
||||||
// UNDERLYING_ASSET_TYPES — keeps each picker's search scoped to the catalog rows that
|
|
||||||
// are actually relevant, instead of both searching the same undifferentiated list.
|
|
||||||
// Saxo has no shared naming convention with yfinance — its catalog search matches on
|
|
||||||
// Saxo's own instrument description text ("Brent Crude"), not the yfinance ticker code
|
|
||||||
// ("BZ=F"). This maps the common yfinance futures-root/index tickers to the plain-English
|
|
||||||
// name worth searching for, so opening a picker pre-fills something useful instead of an
|
|
||||||
// empty box the user has to guess into. Not exhaustive — anything not listed here (most
|
|
||||||
// FX pairs, where the Saxo symbol IS the yfinance root, e.g. "EURUSD=X" -> "EURUSD") falls
|
|
||||||
// back to a best-effort strip of yfinance's own suffix/prefix decoration.
|
|
||||||
const YFINANCE_SEARCH_HINTS: Record<string, string> = {
|
|
||||||
'BZ=F': 'Brent', 'CL=F': 'WTI Crude', 'NG=F': 'Natural Gas',
|
|
||||||
'GC=F': 'Gold', 'SI=F': 'Silver', 'HG=F': 'Copper', 'PL=F': 'Platinum',
|
|
||||||
'^GSPC': 'S&P 500', '^NDX': 'Nasdaq 100', '^DJI': 'Dow Jones', '^RUT': 'Russell 2000',
|
|
||||||
'^VIX': 'VIX', 'IEF': 'Treasury',
|
|
||||||
}
|
|
||||||
|
|
||||||
function guessSaxoSearchHint(ticker: string): string {
|
|
||||||
if (YFINANCE_SEARCH_HINTS[ticker]) return YFINANCE_SEARCH_HINTS[ticker]
|
|
||||||
return ticker.replace(/=F$|=X$|^\^/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const SAXO_LINK_KIND_META = {
|
|
||||||
option: {
|
|
||||||
label: 'Option', placeholder: 'Search Saxo option symbol…',
|
|
||||||
activeCls: 'text-emerald-400 hover:text-emerald-300 bg-emerald-900/20 border-emerald-700/30',
|
|
||||||
assetTypes: 'FuturesOption,FxVanillaOption,StockOption,StockIndexOption',
|
|
||||||
},
|
|
||||||
quote: {
|
|
||||||
label: 'Quote', placeholder: 'Search Saxo spot/futures symbol…',
|
|
||||||
activeCls: 'text-sky-400 hover:text-sky-300 bg-sky-900/20 border-sky-700/30',
|
|
||||||
assetTypes: 'ContractFutures,CfdOnFutures,FxSpot,StockIndex',
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function SaxoLinkPicker({ ticker, kind, saxoSymbol }: { ticker: string; kind: keyof typeof SAXO_LINK_KIND_META; saxoSymbol: string | null }) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [value, setValue] = useState(saxoSymbol ?? '')
|
|
||||||
const [showDropdown, setShowDropdown] = useState(false)
|
|
||||||
const optionLink = useSetWatchlistSaxoOptionLink()
|
|
||||||
const quoteLink = useSetWatchlistSaxoQuoteLink()
|
|
||||||
const { mutate: setLink, isPending } = kind === 'option' ? optionLink : quoteLink
|
|
||||||
const meta = SAXO_LINK_KIND_META[kind]
|
|
||||||
const { data: catalogMatches } = useSaxoCatalog(meta.assetTypes, value.length >= 2 ? value : undefined)
|
|
||||||
|
|
||||||
const save = (sym?: string) => {
|
|
||||||
const resolved = (sym ?? value).trim().toUpperCase() || null
|
|
||||||
setLink({ ticker, saxoSymbol: resolved }, { onSuccess: () => setEditing(false) })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editing) {
|
|
||||||
return saxoSymbol ? (
|
|
||||||
<button onClick={() => { setEditing(true); setValue(saxoSymbol) }} className={clsx('flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded border', meta.activeCls)}>
|
|
||||||
<Link2 className="w-2.5 h-2.5" /> {meta.label}: {saxoSymbol}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => { setEditing(true); setValue(guessSaxoSearchHint(ticker)); setShowDropdown(true) }} className="flex items-center gap-1 text-[9px] text-slate-500 hover:text-slate-300 border border-slate-700/40 px-1.5 py-0.5 rounded">
|
|
||||||
<Unlink className="w-2.5 h-2.5" /> {meta.label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={value}
|
|
||||||
onChange={e => { setValue(e.target.value); setShowDropdown(true) }}
|
|
||||||
onFocus={() => setShowDropdown(true)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false) }}
|
|
||||||
onBlur={() => save()}
|
|
||||||
placeholder={meta.placeholder}
|
|
||||||
className="bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-[9px] text-white w-28 focus:outline-none focus:border-blue-500/50"
|
|
||||||
/>
|
|
||||||
{isPending && <span className="text-[9px] text-slate-600 ml-1">…</span>}
|
|
||||||
{showDropdown && (catalogMatches ?? []).length > 0 && (
|
|
||||||
<div className="absolute z-20 top-full left-0 mt-1 w-72 max-h-60 overflow-y-auto bg-dark-800 border border-slate-700 rounded shadow-xl">
|
|
||||||
{(catalogMatches ?? []).map((c: any) => (
|
|
||||||
<button
|
|
||||||
key={c.symbol}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={e => e.preventDefault()}
|
|
||||||
onClick={() => { setValue(c.symbol); setShowDropdown(false); save(c.symbol) }}
|
|
||||||
className="flex items-center justify-between gap-3 w-full text-left px-2.5 py-1.5 text-[10px] hover:bg-dark-700 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="font-mono font-bold text-white shrink-0">{c.symbol}</span>
|
|
||||||
<span className="text-slate-500 truncate">{c.description}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline-editable display name — the ticker (primary key) no longer has to be a
|
// Inline-editable display name — the ticker (primary key) no longer has to be a
|
||||||
// yfinance-recognized code now that an instrument can be entirely Saxo-sourced, so the
|
// yfinance-recognized code now that an instrument can be entirely Saxo-sourced, so the
|
||||||
// name shouldn't be stuck at whatever yfinance's long_name lookup produced (often just
|
// name shouldn't be stuck at whatever yfinance's long_name lookup produced (often just
|
||||||
@@ -474,6 +380,8 @@ function WatchlistCard() {
|
|||||||
const { data: items, isLoading } = useInstrumentsWatchlist()
|
const { data: items, isLoading } = useInstrumentsWatchlist()
|
||||||
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
|
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
|
||||||
const { mutate: removeTicker } = useRemoveWatchlistInstrument()
|
const { mutate: removeTicker } = useRemoveWatchlistInstrument()
|
||||||
|
const optionLink = useSetWatchlistSaxoOptionLink()
|
||||||
|
const quoteLink = useSetWatchlistSaxoQuoteLink()
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
@@ -543,8 +451,12 @@ function WatchlistCard() {
|
|||||||
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
|
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<SaxoLinkPicker ticker={item.ticker} kind="option" saxoSymbol={item.saxo_option_symbol ?? null} />
|
<SaxoLinkPicker ticker={item.ticker} kind="option" saxoSymbol={item.saxo_option_symbol ?? null}
|
||||||
<SaxoLinkPicker ticker={item.ticker} kind="quote" saxoSymbol={item.saxo_quote_symbol ?? null} />
|
isPending={optionLink.isPending}
|
||||||
|
onSave={sym => optionLink.mutate({ ticker: item.ticker, saxoSymbol: sym })} />
|
||||||
|
<SaxoLinkPicker ticker={item.ticker} kind="quote" saxoSymbol={item.saxo_quote_symbol ?? null}
|
||||||
|
isPending={quoteLink.isPending}
|
||||||
|
onSave={sym => quoteLink.mutate({ ticker: item.ticker, saxoSymbol: sym })} />
|
||||||
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
|
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
|
Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
|
||||||
Minus, BarChart2, Clock, Calendar, AlertCircle,
|
Minus, BarChart2, Clock, Calendar, AlertCircle, Link2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import clsx from 'clsx'
|
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 { useSetInstrumentSaxoLink } 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' })
|
||||||
@@ -122,6 +124,7 @@ interface InstrumentConfig {
|
|||||||
regime_labels: string[]
|
regime_labels: string[]
|
||||||
chart: { ma_periods: number[]; show_volume: boolean }
|
chart: { ma_periods: number[]; show_volume: boolean }
|
||||||
correlation_instruments: string[]
|
correlation_instruments: string[]
|
||||||
|
saxo_quote_symbol?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
|
interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
|
||||||
@@ -182,6 +185,7 @@ interface Snapshot {
|
|||||||
trend: TrendMetrics
|
trend: TrendMetrics
|
||||||
events: SnapshotEvent[]
|
events: SnapshotEvent[]
|
||||||
current_price: number; change_pct: number; change_abs: number; period: string
|
current_price: number; change_pct: number; change_abs: number; period: string
|
||||||
|
source?: 'saxo' | 'yfinance'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -1419,6 +1423,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
const [period, setPeriod] = useState('1y')
|
const [period, setPeriod] = useState('1y')
|
||||||
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 [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)
|
||||||
@@ -1641,6 +1646,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
try {
|
try {
|
||||||
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
|
const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
|
||||||
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
|
const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
|
||||||
|
if (selected.saxo_quote_symbol) params.saxo_symbol = selected.saxo_quote_symbol
|
||||||
if (waveletCausal) params.lookback = waveletLookback
|
if (waveletCausal) params.lookback = waveletLookback
|
||||||
const { data } = await api.get(path, { params })
|
const { data } = await api.get(path, { params })
|
||||||
setWaveletData(data)
|
setWaveletData(data)
|
||||||
@@ -1661,6 +1667,7 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
params: {
|
params: {
|
||||||
symbol: selected.yf_ticker, period, levels: waveletLevels,
|
symbol: selected.yf_ticker, period, levels: waveletLevels,
|
||||||
wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback,
|
wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback,
|
||||||
|
...(selected.saxo_quote_symbol ? { saxo_symbol: selected.saxo_quote_symbol } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setWaveletReliability(data)
|
setWaveletReliability(data)
|
||||||
@@ -1672,6 +1679,19 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-fetch the instrument catalog (source of `selected.saxo_quote_symbol`) and the
|
||||||
|
// current snapshot after linking/unlinking a Saxo symbol — this component manages its
|
||||||
|
// own data via useState/axios rather than React Query, so there's no cache to invalidate.
|
||||||
|
const handleSaxoLinkSave = (saxoSymbol: string | null) => {
|
||||||
|
if (!instrumentId) return
|
||||||
|
setSaxoLink.mutate({ instrumentId, saxoSymbol }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
|
||||||
|
fetchSnapshot()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const waveletChartData = useMemo(() => {
|
const waveletChartData = useMemo(() => {
|
||||||
if (!waveletData) return []
|
if (!waveletData) return []
|
||||||
const { dates, original, bands, mean } = waveletData
|
const { dates, original, bands, mean } = waveletData
|
||||||
@@ -1907,6 +1927,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
|
||||||
|
isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} />
|
||||||
|
)}
|
||||||
|
|
||||||
{displayPrice !== undefined && (
|
{displayPrice !== undefined && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl font-bold text-white">
|
<span className="text-xl font-bold text-white">
|
||||||
@@ -1917,6 +1942,11 @@ export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { i
|
|||||||
{(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
|
{(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{snapshot?.source === 'saxo' && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-emerald-500" title="Prix source Saxo">
|
||||||
|
<Link2 className="w-3 h-3" /> ● Saxo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user