feat: cockpit

This commit is contained in:
OpenSquared
2026-07-24 11:46:37 +02:00
parent ae732c5822
commit 35a00f43c3
6 changed files with 201 additions and 13 deletions

View File

@@ -16,6 +16,7 @@ from services.instrument_service import (
get_narrative,
update_instrument_drivers,
update_instrument_saxo_link,
quick_add_instrument_from_watchlist,
)
class DriverUpdate(BaseModel):
@@ -25,6 +26,10 @@ class DriverUpdate(BaseModel):
class SaxoLinkBody(BaseModel):
saxo_symbol: Optional[str] = None
class QuickAddBody(BaseModel):
ticker: str
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
@@ -36,6 +41,16 @@ def list_instruments() -> List[Dict[str, Any]]:
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")
async def instrument_snapshot(
instrument_id: str,

View File

@@ -287,6 +287,13 @@ def init_db():
drivers_json TEXT,
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:
c.execute(_sql)
@@ -3406,15 +3413,20 @@ def rename_instrument_watchlist(ticker: str, name: str) -> bool:
def get_instrument_overrides() -> Dict[str, Dict]:
"""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()
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()
out = {}
for r in rows:
out[r["instrument_id"]] = {
"saxo_quote_symbol": r["saxo_quote_symbol"],
"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
@@ -3444,6 +3456,28 @@ def set_instrument_override_drivers(instrument_id: str, drivers: List[Dict]) ->
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 ─────────────────────────────
def save_wavelet_simulation(name: str, form: Dict, results: Optional[List[Dict]] = None,

View File

@@ -28,6 +28,28 @@ _configs: Optional[Dict[str, Any]] = None
_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:
"""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
@@ -40,7 +62,8 @@ def _load_configs() -> None:
_configs = {inst["id"]: inst for inst in data["instruments"]}
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:
continue
# 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:
_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")
@@ -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}")
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 ──────────────────────────────────────────────────────────
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame: