diff --git a/backend/main.py b/backend/main.py index 37ea1ad..0ade838 100644 --- a/backend/main.py +++ b/backend/main.py @@ -114,6 +114,14 @@ def startup(): _log.info("[Startup] Instrument models seeded") except Exception as _e: _log.warning(f"[Startup] Instrument models seed failed: {_e}") + # Backfill wavelet_engine/extremum/level_threshold defaults onto the Technical + # Desk so the AI Desks toggle UI matches what's actually computed each cycle + try: + from services.database import backfill_wavelet_desk_defaults + backfill_wavelet_desk_defaults() + _log.info("[Startup] Wavelet desk defaults backfilled") + except Exception as _e: + _log.warning(f"[Startup] Wavelet desk defaults backfill failed: {_e}") # Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline # Start auto-cycle scheduler if enabled from services.auto_cycle import start_scheduler diff --git a/backend/routers/ai_chat.py b/backend/routers/ai_chat.py index 852273f..5d5a98f 100644 --- a/backend/routers/ai_chat.py +++ b/backend/routers/ai_chat.py @@ -1,6 +1,8 @@ """ -Free-form, read-only chat with GPT-4o about the current cockpit state. -No function-calling — this endpoint can never trigger an action. +Free-form chat with GPT-4o about the current cockpit state. +The only tool the model can call (propose_trade) just writes a pending row to +ai_trade_proposals — it never touches the real portfolio. Confirm/reject below +are the only way a proposal turns into (or is discarded from) a real position. """ from typing import List, Optional @@ -58,3 +60,50 @@ def clear_session(body: ClearBody): clear_chat_session(body.session_id) clear_context_cache(body.session_id) return {"cleared": body.session_id} + + +@router.get("/trade-proposals") +def list_trade_proposals(status: str = "pending"): + from services.database import get_ai_trade_proposals + return {"proposals": get_ai_trade_proposals(status=status)} + + +@router.post("/trade-proposals/{proposal_id}/confirm") +def confirm_trade_proposal(proposal_id: str): + """Promotes a pending AI proposal into a real open position, reusing the + same enrichment (live price, Black-Scholes leg pricing) as a manual add.""" + from services.database import get_ai_trade_proposal, resolve_ai_trade_proposal + from routers.portfolio import add_pos, AddPositionRequest + + proposal = get_ai_trade_proposal(proposal_id) + if not proposal: + raise HTTPException(404, "Proposition introuvable.") + if proposal["status"] != "pending": + raise HTTPException(400, f"Proposition deja {proposal['status']}.") + + req = AddPositionRequest( + title=proposal["title"], + underlying=proposal["underlying"], + strategy=proposal["strategy"], + asset_class=proposal.get("asset_class") or "indices", + expiry_days=proposal.get("expiry_days") or 90, + legs=proposal.get("legs") or [], + capital_invested=proposal["capital_invested"], + geo_trigger=proposal.get("geo_trigger") or "", + rationale=proposal.get("rationale") or "", + ) + result = add_pos(req) + resolve_ai_trade_proposal(proposal_id, "confirmed", portfolio_id=result["id"]) + return {"status": "confirmed", "portfolio_id": result["id"]} + + +@router.post("/trade-proposals/{proposal_id}/reject") +def reject_trade_proposal(proposal_id: str): + from services.database import get_ai_trade_proposal, resolve_ai_trade_proposal + proposal = get_ai_trade_proposal(proposal_id) + if not proposal: + raise HTTPException(404, "Proposition introuvable.") + if proposal["status"] != "pending": + raise HTTPException(400, f"Proposition deja {proposal['status']}.") + resolve_ai_trade_proposal(proposal_id, "rejected") + return {"status": "rejected"} diff --git a/backend/routers/ai_desks.py b/backend/routers/ai_desks.py index 350bbee..662ab7f 100644 --- a/backend/routers/ai_desks.py +++ b/backend/routers/ai_desks.py @@ -89,6 +89,84 @@ SIGNAL_CATALOG: List[Dict[str, Any]] = [ "signal": {"type": "int", "label": "Signal", "default": 9, "min": 3, "max": 20}, }, }, + # ── Wavelets — décomposition en bandes de fréquence sur la watchlist ──── + { + "id": "wavelet_engine", + "label": "Ondelettes — moteur", + "description": "Paramètres partagés du calcul (désactive tous les signaux ondelettes si décoché)", + "desk_type": "technical", + "params": { + "num_levels": {"type": "int", "label": "Nb bandes", "default": 4, "min": 2, "max": 6}, + "wavelet": {"type": "select", "label": "Famille", "default": "gmw", "options": ["gmw", "morlet", "bump"]}, + "method": {"type": "select", "label": "Méthode", "default": "cwt", "options": ["cwt", "ssq"]}, + "lookback_days": {"type": "int", "label": "Lookback (j)", "default": 120, "min": 60, "max": 250}, + }, + }, + { + "id": "wavelet_extremum", + "label": "Ondelettes — extremum", + "description": "Pic ou creux confirmé sur une bande", + "desk_type": "technical", + "params": {}, + }, + { + "id": "wavelet_level_threshold", + "label": "Ondelettes — seuil de niveau", + "description": "La bande dépasse un seuil de z-score causal (sur/sous-achetée)", + "desk_type": "technical", + "params": { + "threshold_k": {"type": "float", "label": "Seuil (écarts-type)", "default": 2.0, "min": 1.0, "max": 4.0}, + }, + }, + { + "id": "wavelet_trend_flatten", + "label": "Ondelettes — tendance puis tassement", + "description": "Forte pente suivie d'un aplatissement — signal de fin de mouvement", + "desk_type": "technical", + "params": { + "trend_days": {"type": "int", "label": "Jours tendance", "default": 10, "min": 3, "max": 30}, + "flatten_days": {"type": "int", "label": "Jours tassement", "default": 5, "min": 2, "max": 15}, + "trend_threshold_k": {"type": "float", "label": "Seuil tendance", "default": 1.0, "min": 0.3, "max": 3.0}, + "flatten_threshold_k": {"type": "float", "label": "Seuil tassement", "default": 0.3, "min": 0.1, "max": 1.5}, + }, + }, + { + "id": "wavelet_acceleration", + "label": "Ondelettes — déceleration/accélération", + "description": "Accélération soutenue en sens inverse de la pente — signal de retournement", + "desk_type": "technical", + "params": { + "accel_days": {"type": "int", "label": "Jours consécutifs", "default": 3, "min": 1, "max": 10}, + "accel_threshold_k":{"type": "float", "label": "Seuil (écarts-type)", "default": 1.5, "min": 0.5, "max": 4.0}, + }, + }, + { + "id": "wavelet_band_cross", + "label": "Ondelettes — croisement de bandes", + "description": "Une bande croise une bande secondaire", + "desk_type": "technical", + "params": { + "secondary_band": {"type": "int", "label": "Index bande secondaire", "default": 1, "min": 0, "max": 5}, + }, + }, + { + "id": "wavelet_ridge_shift", + "label": "Ondelettes — bascule de ridge", + "description": "Le cycle dominant (ridge SSQ) dévie de sa moyenne — nécessite méthode = ssq", + "desk_type": "technical", + "params": { + "threshold_k": {"type": "float", "label": "Seuil (écarts-type)", "default": 2.0, "min": 1.0, "max": 4.0}, + }, + }, + { + "id": "wavelet_energy_threshold", + "label": "Ondelettes — seuil d'énergie", + "description": "L'énergie d'une bande dépasse un seuil — nécessite méthode = ssq", + "desk_type": "technical", + "params": { + "threshold_k": {"type": "float", "label": "Seuil (écarts-type)", "default": 2.0, "min": 1.0, "max": 4.0}, + }, + }, # ── Sentiment signals ─────────────────────────────────────────────────── { "id": "vix_level", diff --git a/backend/services/ai_chat.py b/backend/services/ai_chat.py index 3619f86..a001916 100644 --- a/backend/services/ai_chat.py +++ b/backend/services/ai_chat.py @@ -1,11 +1,14 @@ """ -Free-form, read-only chat with GPT-4o about the current cockpit state. +Free-form chat with GPT-4o about the current cockpit state. -Deliberately has NO function-calling/tools wired up — a plain text-completion -call physically cannot trigger any action (no trade, no cycle, no DB write -beyond persisting the conversation itself). The system prompt also tells the -model explicitly not to claim it can act, so it doesn't mislead the user. +The only action the model can trigger is `propose_trade` — even then, it never +touches the real portfolio: the tool call just writes a 'pending' row to +ai_trade_proposals. The user has to explicitly confirm from the Trade Ideas UI +(POST /api/ai-chat/trade-proposals/{id}/confirm) before anything becomes a real +position. No other tool is wired up, so nothing else can ever be triggered from +here (no cycle, no data mutation, no close/edit of existing positions). """ +import json import re import time from typing import Dict, List, Optional @@ -18,7 +21,8 @@ Tu as acces ci-dessous a un instantane en lecture seule de la situation actuelle REGLES IMPORTANTES : - Quand on te demande une idee ou un conseil de trade, PROPOSE quelque chose de concret (biais directionnel, instrument, montage d'options avec strikes/echeance si pertinent, niveaux techniques, justification tiree du contexte) - exactement comme le ferait le cycle automatique dans ses recommandations. Ne te contente pas d'observations vagues ni de renvoyer la question : prends position a partir du contexte fourni. -- La seule limite reelle est que tu ne peux EXECUTER aucune action toi-meme (aucun trade n'est passe, aucun cycle n'est declenche, aucune donnee n'est modifiee) - tes idees sont des suggestions que l'utilisateur doit valider et executer lui-meme ailleurs dans le cockpit. Ne le precise que si l'utilisateur semble croire que tu peux agir directement (ex. "achete X pour moi"). +- Tu disposes de l'outil propose_trade pour enregistrer une idee concrete. Utilise-le UNIQUEMENT quand l'utilisateur demande explicitement un conseil de trade ou valide clairement une idee que tu viens de suggerer - jamais de maniere systematique a chaque message. Chaque appel cree une proposition EN ATTENTE dans Trade Ideas ; rien n'est jamais execute automatiquement. +- La seule limite reelle est que tu ne peux EXECUTER aucune action toi-meme (aucun trade n'est passe directement, aucun cycle n'est declenche, aucune position existante n'est modifiee) - tes idees sont des suggestions que l'utilisateur doit valider lui-meme. Ne le precise que si l'utilisateur semble croire que tu peux agir directement (ex. "achete X pour moi"). - Reponds en francais, de facon concise et directe, en t'appuyant sur le contexte fourni. Si une donnee demandee n'est pas dans le contexte ci-dessous, dis-le plutot que d'inventer. === CONTEXTE ACTUEL === @@ -26,11 +30,55 @@ REGLES IMPORTANTES : === FIN DU CONTEXTE === """ +TRADE_PROPOSAL_TOOL = { + "type": "function", + "function": { + "name": "propose_trade", + "description": ( + "Enregistre une idee de trade concrete EN ATTENTE dans Trade Ideas. " + "N'execute RIEN et n'ouvre AUCUNE position — l'utilisateur doit explicitement " + "confirmer depuis l'interface pour que ca devienne un trade reel dans le portefeuille. " + "N'appelle cet outil que lorsque l'utilisateur demande explicitement un conseil de trade " + "ou valide clairement une idee que tu as suggeree — jamais de maniere systematique." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Titre court de l'idee"}, + "underlying": {"type": "string", "description": "Ticker Yahoo Finance du sous-jacent (ex: EURUSD=X, CL=F, SPY, GC=F)"}, + "strategy": {"type": "string", "description": "Nom de la strategie (ex: long call, put spread, straddle, short strangle, directionnel spot)"}, + "asset_class": {"type": "string", "enum": ["indices", "forex", "commodities", "rates", "crypto", "equities"]}, + "expiry_days": {"type": "integer", "description": "Horizon en jours jusqu'a l'echeance"}, + "capital_invested": {"type": "number", "description": "Capital alloue en EUR"}, + "legs": { + "type": "array", + "description": "Legs optionnelles du montage (liste vide pour un trade directionnel simple sans options)", + "items": { + "type": "object", + "properties": { + "strike": {"type": "number"}, + "option_type": {"type": "string", "enum": ["call", "put"]}, + "quantity": {"type": "integer"}, + "position": {"type": "string", "enum": ["long", "short"]}, + }, + "required": ["strike", "option_type", "quantity", "position"], + }, + }, + "geo_trigger": {"type": "string", "description": "Evenement/catalyseur declencheur, si pertinent"}, + "rationale": {"type": "string", "description": "Justification concise, appuyee sur le contexte fourni"}, + }, + "required": ["title", "underlying", "strategy", "capital_invested", "rationale"], + }, + }, +} -def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200) -> str: + +def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max_tokens: int = 1200, tools: Optional[List[Dict]] = None): """Multi-turn variant of ai_analyzer._chat() — accepts a full message history - instead of a single system+user pair. Same client/retry/backoff logic, kept - independent so it never risks the well-tested cycle-facing _chat().""" + instead of a single system+user pair, and optionally OpenAI tool schemas. + Returns the raw SDK message object (not just its text) so callers can + inspect tool_calls. Same client/retry/backoff logic, kept independent so it + never risks the well-tested cycle-facing _chat().""" client = get_client() if not client: raise RuntimeError("OpenAI API key not configured") @@ -41,12 +89,14 @@ def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max "temperature": 0.4, "max_tokens": max_tokens, } + if tools: + kwargs["tools"] = tools last_exc: Optional[Exception] = None for attempt in range(4): try: resp = client.chat.completions.create(**kwargs) - return resp.choices[0].message.content or "" + return resp.choices[0].message except Exception as e: last_exc = e err_str = str(e) @@ -59,6 +109,27 @@ def _chat_messages(system: str, messages: List[Dict], model: str = "gpt-4o", max raise last_exc # type: ignore[misc] +def _handle_tool_call(tc, session_id: str) -> tuple: + """Executes one tool call. Returns (tool_result_text, trade_proposal_or_None).""" + from services.database import save_ai_trade_proposal + + if tc.function.name != "propose_trade": + return "Outil inconnu.", None + + try: + args = json.loads(tc.function.arguments or "{}") + proposal_id = save_ai_trade_proposal({**args, "session_id": session_id}) + trade_proposal = { + "id": proposal_id, + "title": args.get("title"), + "underlying": args.get("underlying"), + "strategy": args.get("strategy"), + } + return f"Proposition enregistree (id={proposal_id}), EN ATTENTE dans Trade Ideas. Rien n'a ete execute.", trade_proposal + except Exception as e: + return f"Erreur lors de l'enregistrement de la proposition: {e}", None + + def send_chat_message( session_id: str, message: str, @@ -77,7 +148,29 @@ def send_chat_message( messages.append({"role": "user", "content": message}) save_chat_message(session_id, "user", message) - reply = _chat_messages(system, messages) - save_chat_message(session_id, "assistant", reply) - return {"reply": reply, "blocks_included": list(blocks.keys())} + reply_msg = _chat_messages(system, messages, tools=[TRADE_PROPOSAL_TOOL]) + trade_proposal = None + + if reply_msg.tool_calls: + messages.append({ + "role": "assistant", + "content": reply_msg.content, + "tool_calls": [ + {"id": tc.id, "type": "function", "function": {"name": tc.function.name, "arguments": tc.function.arguments}} + for tc in reply_msg.tool_calls + ], + }) + for tc in reply_msg.tool_calls: + tool_result, proposal = _handle_tool_call(tc, session_id) + trade_proposal = proposal or trade_proposal + messages.append({"role": "tool", "tool_call_id": tc.id, "content": tool_result}) + + final_msg = _chat_messages(system, messages) # no tools this round — forces a final text reply + reply_text = final_msg.content or "" + else: + reply_text = reply_msg.content or "" + + save_chat_message(session_id, "assistant", reply_text) + + return {"reply": reply_text, "blocks_included": list(blocks.keys()), "trade_proposal": trade_proposal} diff --git a/backend/services/ai_chat_context.py b/backend/services/ai_chat_context.py index 24e0e3a..d8f773c 100644 --- a/backend/services/ai_chat_context.py +++ b/backend/services/ai_chat_context.py @@ -87,13 +87,29 @@ def _block_tech_indicators() -> str: def _block_wavelet_signals() -> str: - from services.database import get_latest_wavelet_signals - signals = get_latest_wavelet_signals() - if not signals: - return "## WAVELET SIGNALS\nNo wavelet signal detected yet (computed each auto-cycle)." - lines = ["## WAVELET SIGNALS (watchlist, latest cycle scan)"] - for s in signals[:20]: - lines.append(f"- {s['ticker']}: band {s['band_label']} · {s['signal_kind']} · {s['direction']} @ {s.get('price_at_signal')}") + from services.database import get_latest_wavelet_state + rows = get_latest_wavelet_state() + if not rows: + return "## WAVELET SIGNALS\nNo wavelet state computed yet (computed each auto-cycle for the watchlist instruments)." + + by_ticker: Dict[str, List[Dict]] = {} + for r in rows: + by_ticker.setdefault(r["ticker"], []).append(r) + + lines = ["## WAVELET SIGNALS (watchlist, latest cycle — slope/energy/ridge state + any active trigger)"] + for ticker, band_rows in list(by_ticker.items())[:12]: + lines.append(f"### {ticker}") + for r in band_rows: + tag = f" -> SIGNAL {r['signal_kind']} ({r['direction']})" if r.get("signal_kind") else "" + if r["band_label"] == "ridge": + if r.get("ridge_period_days") is not None: + lines.append(f"- ridge (cycle dominant): {r['ridge_period_days']:.1f}j{tag}") + continue + period = f"{r['period_low_days']}-{r['period_high_days']}j" if r.get("period_low_days") is not None else r["band_label"] + slope = r.get("slope") + slope_txt = f"pente {'+' if slope >= 0 else ''}{slope:.4f}" if slope is not None else "pente n/a" + energy_txt = f", energie {r['energy']:.4f}" if r.get("energy") is not None else "" + lines.append(f"- {r['band_label']} [{period}]: valeur {r.get('value')}, {slope_txt}{energy_txt}{tag}") return "\n".join(lines) diff --git a/backend/services/database.py b/backend/services/database.py index fc6ca0f..14bd005 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -143,6 +143,32 @@ def init_db(): content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')) )""", + # AI Chat widget — trade ideas proposed by the AI via function-calling, pending + # user confirmation before they ever touch the real portfolio table + """CREATE TABLE IF NOT EXISTS ai_trade_proposals ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + status TEXT DEFAULT 'pending', + title TEXT NOT NULL, + underlying TEXT NOT NULL, + strategy TEXT NOT NULL, + asset_class TEXT DEFAULT 'indices', + expiry_days INTEGER DEFAULT 90, + capital_invested REAL NOT NULL, + legs_json TEXT NOT NULL, + geo_trigger TEXT DEFAULT '', + rationale TEXT DEFAULT '', + portfolio_id TEXT, + resolved_at TEXT + )""", + # Wavelets — richer per-cycle state (slope/energy/ridge), one row per (ticker, band) + # every cycle regardless of whether a signal fired (was: only on firing) + "ALTER TABLE wavelet_watchlist_signals ADD COLUMN slope REAL", + "ALTER TABLE wavelet_watchlist_signals ADD COLUMN value REAL", + "ALTER TABLE wavelet_watchlist_signals ADD COLUMN energy REAL", + "ALTER TABLE wavelet_watchlist_signals ADD COLUMN ridge_period_days REAL", + "ALTER TABLE wavelet_watchlist_signals ADD COLUMN params_json TEXT", ]: try: c.execute(_sql) @@ -153,6 +179,10 @@ def init_db(): c.execute("CREATE INDEX IF NOT EXISTS idx_wws_ticker_date ON wavelet_watchlist_signals(ticker, computed_at DESC)") except Exception: pass + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_atp_session_status ON ai_trade_proposals(session_id, status)") + except Exception: + pass try: c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)") @@ -3192,25 +3222,51 @@ def save_wavelet_signals(run_id: str, signals: List[Dict]) -> None: for s in signals: conn.execute( "INSERT INTO wavelet_watchlist_signals " - "(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal, " + "slope, value, energy, ridge_period_days, params_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (run_id, s.get("ticker"), s.get("band_label"), s.get("period_low_days"), s.get("period_high_days"), - s.get("signal_kind"), s.get("direction"), s.get("price_at_signal")), + s.get("signal_kind"), s.get("direction"), s.get("price_at_signal"), + s.get("slope"), s.get("value"), s.get("energy"), s.get("ridge_period_days"), s.get("params_json")), ) conn.commit() conn.close() +def _latest_wavelet_run_id() -> Optional[str]: + conn = get_conn() + row = conn.execute( + "SELECT run_id FROM wavelet_watchlist_signals ORDER BY computed_at DESC LIMIT 1" + ).fetchone() + conn.close() + return row["run_id"] if row else None + + def get_latest_wavelet_signals() -> List[Dict]: - """Most recent signal per ticker (one row per ticker, its latest computed_at).""" + """Fired signals only (signal_kind IS NOT NULL) from the most recent cycle + scan — feeds the Dashboard 'Wavelets Signal' card, unchanged behavior.""" + run_id = _latest_wavelet_run_id() + if not run_id: + return [] conn = get_conn() rows = conn.execute( - """SELECT w.* FROM wavelet_watchlist_signals w - INNER JOIN ( - SELECT ticker, MAX(computed_at) AS max_computed_at - FROM wavelet_watchlist_signals GROUP BY ticker - ) latest ON w.ticker = latest.ticker AND w.computed_at = latest.max_computed_at - ORDER BY w.computed_at DESC""" + "SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND signal_kind IS NOT NULL ORDER BY computed_at DESC", + (run_id,), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_latest_wavelet_state() -> List[Dict]: + """Every (ticker, band) row from the most recent cycle scan — signal or + not. Used for the rich AI chat context block (slope/energy/ridge state).""" + run_id = _latest_wavelet_run_id() + if not run_id: + return [] + conn = get_conn() + rows = conn.execute( + "SELECT * FROM wavelet_watchlist_signals WHERE run_id=? ORDER BY ticker, band_label", + (run_id,), ).fetchall() conn.close() return [dict(r) for r in rows] @@ -3255,6 +3311,72 @@ def clear_chat_session(session_id: str) -> None: conn.close() +# ── AI Chat widget — trade proposals (pending confirmation) ──────────────────── + +def save_ai_trade_proposal(proposal: Dict[str, Any]) -> str: + import uuid + proposal_id = uuid.uuid4().hex + conn = get_conn() + conn.execute( + """INSERT INTO ai_trade_proposals ( + id, session_id, status, title, underlying, strategy, asset_class, + expiry_days, capital_invested, legs_json, geo_trigger, rationale + ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + proposal_id, proposal["session_id"], proposal["title"], proposal["underlying"], + proposal["strategy"], proposal.get("asset_class", "indices"), + proposal.get("expiry_days", 90), proposal["capital_invested"], + json.dumps(proposal.get("legs", [])), proposal.get("geo_trigger", ""), + proposal.get("rationale", ""), + ), + ) + conn.commit() + conn.close() + return proposal_id + + +def get_ai_trade_proposal(proposal_id: str) -> Optional[Dict[str, Any]]: + conn = get_conn() + row = conn.execute("SELECT * FROM ai_trade_proposals WHERE id=?", (proposal_id,)).fetchone() + conn.close() + if not row: + return None + d = dict(row) + try: + d["legs"] = json.loads(d.pop("legs_json") or "[]") + except Exception: + d["legs"] = [] + return d + + +def get_ai_trade_proposals(status: str = "pending") -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM ai_trade_proposals WHERE status=? ORDER BY created_at DESC", + (status,), + ).fetchall() + conn.close() + out = [] + for r in rows: + d = dict(r) + try: + d["legs"] = json.loads(d.pop("legs_json") or "[]") + except Exception: + d["legs"] = [] + out.append(d) + return out + + +def resolve_ai_trade_proposal(proposal_id: str, status: str, portfolio_id: Optional[str] = None) -> None: + conn = get_conn() + conn.execute( + "UPDATE ai_trade_proposals SET status=?, portfolio_id=?, resolved_at=datetime('now') WHERE id=?", + (status, portfolio_id, proposal_id), + ) + conn.commit() + conn.close() + + # ── System Logs ─────────────────────────────────────────────────────────────── def log_system_event( @@ -5498,6 +5620,32 @@ def get_ai_desk_by_type(desk_type: str) -> Optional[Dict[str, Any]]: return next((d for d in desks if d["type"] == desk_type and d.get("active")), None) +def backfill_wavelet_desk_defaults() -> None: + """One-time idempotent patch for Technical Desks created before the wavelet + signal catalog existed: wavelet_signals.py treats wavelet_engine/ + wavelet_extremum/wavelet_level_threshold as enabled when absent from + config.signals (preserves the always-on pre-desk-config behavior), but the + AI Desks toggle UI shows a missing key as OFF — writing the explicit + defaults here keeps what the UI displays honest about what's computed.""" + desk = get_ai_desk_by_type("technical") + if not desk: + return + signals = (desk.get("config") or {}).get("signals") or {} + defaults = { + "wavelet_engine": {"enabled": True, "num_levels": 4, "wavelet": "gmw", "method": "cwt", "lookback_days": 120}, + "wavelet_extremum": {"enabled": True}, + "wavelet_level_threshold": {"enabled": True, "threshold_k": 2.0}, + } + changed = False + for key, val in defaults.items(): + if key not in signals: + signals[key] = val + changed = True + if changed: + desk["config"]["signals"] = signals + update_ai_desk_by_id(desk["id"], desk) + + def upsert_ai_desk(desk: Dict[str, Any]) -> int: conn = get_conn() try: diff --git a/backend/services/wavelet_signals.py b/backend/services/wavelet_signals.py index 666b4b1..04936f3 100644 --- a/backend/services/wavelet_signals.py +++ b/backend/services/wavelet_signals.py @@ -2,17 +2,53 @@ Automated wavelet signal detection for the watchlist — run once per cycle. Ported (Python subset) from the trigger-signal detectors in -c:\\DataS\\InstrumentSimulator\\frontend\\src\\main.tsx (lines 180-280, TypeScript). -Only `extremum` and `level_threshold` are ported here: they're self-contained -(single curve, no secondary curve/config needed) and robust enough for an -unattended scan. The richer configurable trigger set (trend_flatten, -acceleration, band_cross, ridge_shift, energy_threshold) stays exclusive to the -interactive Wavelets Simulation page (frontend/src/lib/waveletTrade.ts), where a -user picks and tunes them explicitly. +c:\\DataS\\InstrumentSimulator\\frontend\\src\\main.tsx / frontend/src/lib/waveletTrade.ts. +All 7 trigger kinds from the interactive Wavelets Simulation page are now +available here: extremum, level_threshold, trend_flatten, acceleration, +band_cross, ridge_shift (ssq only), energy_threshold (ssq only). + +Parameters (engine + per-signal enable/thresholds) come from the "Technical +Desk" (services.database.get_ai_desk_by_type("technical"), config.signals.wavelet_*) +so they're editable from the existing AI Desks config UI — no hardcoded +defaults here beyond a safe fallback when the desk/key is absent. The +instrument scope stays get_instruments_watchlist() (the desk's own +`instruments` list is NOT used, to avoid reintroducing a second overlapping +instrument-list source). + +Every (ticker, band) gets a row every cycle now — signal or not — so the AI +chat context always has fresh slope/energy/ridge state, not just firing +events (see ai_chat_context.py:_block_wavelet_signals). """ +import json from typing import Dict, List, Optional +def _compute_slope(series: List[float]) -> List[float]: + n = len(series) + slope = [0.0] * n + for i in range(1, n): + slope[i] = series[i] - series[i - 1] + if n > 1: + slope[0] = slope[1] + return slope + + +def _compute_acceleration(slope: List[float]) -> List[float]: + n = len(slope) + accel = [0.0] * n + for i in range(1, n): + accel[i] = slope[i] - slope[i - 1] + if n > 1: + accel[0] = accel[1] + return accel + + +def _avg_slope_range(slope: List[float], frm: int, to: int) -> Optional[float]: + if frm < 0 or to > len(slope) - 1 or to <= frm: + return None + return sum(slope[frm + 1:to + 1]) / (to - frm) + + def _build_extremum_signal(series: List[float], direction: str) -> List[bool]: n = len(series) raw = [False] * n @@ -52,6 +88,88 @@ def _build_level_threshold_signal(series: List[float], direction: str, threshold return signal +def _build_trend_flatten_signal(series: List[float], direction: str, trend_days: int, flatten_days: int, + trend_threshold_k: float, flatten_threshold_k: float) -> List[bool]: + n = len(series) + slope = _compute_slope(series) + signal = [False] * n + s = 0.0 + sq = 0.0 + for t in range(1, n): + s += slope[t] + sq += slope[t] * slope[t] + count = t + if t < trend_days + flatten_days or count < 20: + continue + mean = s / count + variance = max(0.0, sq / count - mean * mean) + std = variance ** 0.5 + trend_thresh = trend_threshold_k * std + flatten_thresh = flatten_threshold_k * std + trend = _avg_slope_range(slope, t - flatten_days - trend_days, t - flatten_days) + flat = _avg_slope_range(slope, t - flatten_days, t) + if trend is None or flat is None: + continue + if direction == "up" and trend > trend_thresh and abs(flat) <= flatten_thresh: + signal[t] = True + if direction == "down" and trend < -trend_thresh and abs(flat) <= flatten_thresh: + signal[t] = True + return signal + + +def _build_acceleration_signal(series: List[float], direction: str, days: int, threshold_k: float) -> List[bool]: + n = len(series) + slope = _compute_slope(series) + accel = _compute_acceleration(slope) + signal = [False] * n + s = 0.0 + sq = 0.0 + for t in range(2, n): + s += accel[t] + sq += accel[t] * accel[t] + count = t - 1 + if t < days or count < 20: + continue + mean = s / count + variance = max(0.0, sq / count - mean * mean) + std = variance ** 0.5 + thresh = threshold_k * std + if direction == "up": + if slope[t] <= 0: + continue + ok = True + for d in range(days): + idx = t - d + if idx < 0 or not (accel[idx] < -thresh): + ok = False + break + signal[t] = ok + else: + if slope[t] >= 0: + continue + ok = True + for d in range(days): + idx = t - d + if idx < 0 or not (accel[idx] > thresh): + ok = False + break + signal[t] = ok + return signal + + +def _build_band_cross_signal(primary: List[float], secondary: List[float], direction: str) -> List[bool]: + n = min(len(primary), len(secondary)) + signal = [False] * n + for t in range(1, n): + prev_diff = primary[t - 1] - secondary[t - 1] + curr_diff = primary[t] - secondary[t] + if direction == "down" and prev_diff >= 0 and curr_diff < 0: + signal[t] = True + if direction == "up" and prev_diff <= 0 and curr_diff > 0: + signal[t] = True + return signal + + def detect_extremum_signal(series: List[float]) -> Optional[str]: """Returns 'up' (confirmed peak) or 'down' (confirmed trough) if the most recent point is a signal, else None.""" @@ -76,16 +194,73 @@ def detect_level_threshold_signal(series: List[float], threshold_k: float = 2.0) return None -def scan_watchlist_wavelet_signals(num_levels: int = 4, wavelet: str = "gmw", lookback: int = 120, method: str = "cwt") -> List[Dict]: +def detect_trend_flatten_signal(series: List[float], trend_days: int = 10, flatten_days: int = 5, + trend_threshold_k: float = 1.0, flatten_threshold_k: float = 0.3) -> Optional[str]: + if len(series) < trend_days + flatten_days + 20: + return None + if _build_trend_flatten_signal(series, "up", trend_days, flatten_days, trend_threshold_k, flatten_threshold_k)[-1]: + return "up" + if _build_trend_flatten_signal(series, "down", trend_days, flatten_days, trend_threshold_k, flatten_threshold_k)[-1]: + return "down" + return None + + +def detect_acceleration_signal(series: List[float], accel_days: int = 3, accel_threshold_k: float = 1.5) -> Optional[str]: + if len(series) < accel_days + 20: + return None + if _build_acceleration_signal(series, "up", accel_days, accel_threshold_k)[-1]: + return "up" + if _build_acceleration_signal(series, "down", accel_days, accel_threshold_k)[-1]: + return "down" + return None + + +def detect_band_cross_signal(primary: List[float], secondary: List[float]) -> Optional[str]: + if len(primary) < 2 or len(secondary) < 2: + return None + if _build_band_cross_signal(primary, secondary, "up")[-1]: + return "up" + if _build_band_cross_signal(primary, secondary, "down")[-1]: + return "down" + return None + + +def _technical_desk_wavelet_config() -> Dict: + from services.database import get_ai_desk_by_type + desk = get_ai_desk_by_type("technical") or {} + return (desk.get("config") or {}).get("signals") or {} + + +def scan_watchlist_wavelet_signals() -> List[Dict]: """Compute a causal (no-look-ahead) band decomposition for each watchlist - instrument and flag any band whose most recent point is a signal. Only the - trailing ~60 output points are computed (not the whole history) — this scan - only needs to know about *today*, unlike the interactive Simulation page's - full-range backtest.""" + instrument. Every (ticker, band) gets a row every cycle — current slope/ + value/energy state always, plus signal_kind/direction/params_json when one + of the enabled trigger kinds fires on the most recent point (first match + wins, evaluated extremum -> level_threshold -> trend_flatten -> + acceleration -> band_cross -> energy_threshold). ridge_shift is evaluated + once per ticker (not per band — the ridge is a single track for the whole + decomposition) and stored as an extra band_label="ridge" row.""" from services.database import get_instruments_watchlist from services.data_fetcher import get_historical from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq + sig_cfg = _technical_desk_wavelet_config() + engine_cfg = sig_cfg.get("wavelet_engine") or {} + if not engine_cfg.get("enabled", True): + return [] + num_levels = int(engine_cfg.get("num_levels", 4)) + wavelet = engine_cfg.get("wavelet", "gmw") + method = engine_cfg.get("method", "cwt") + lookback = int(engine_cfg.get("lookback_days", 120)) + + extremum_cfg = sig_cfg.get("wavelet_extremum") or {"enabled": True} + level_cfg = sig_cfg.get("wavelet_level_threshold") or {"enabled": True, "threshold_k": 2.0} + trend_cfg = sig_cfg.get("wavelet_trend_flatten") or {"enabled": False} + accel_cfg = sig_cfg.get("wavelet_acceleration") or {"enabled": False} + cross_cfg = sig_cfg.get("wavelet_band_cross") or {"enabled": False} + ridge_cfg = sig_cfg.get("wavelet_ridge_shift") or {"enabled": False} + energy_cfg = sig_cfg.get("wavelet_energy_threshold") or {"enabled": False} + results: List[Dict] = [] decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands @@ -106,23 +281,94 @@ def scan_watchlist_wavelet_signals(num_levels: int = 4, wavelet: str = "gmw", lo if not decomposed["dates"]: continue price_at_signal = decomposed["original"][-1] + bands = decomposed["bands"] - for band in decomposed["bands"]: + for i, band in enumerate(bands): series = band["series"] - direction = detect_extremum_signal(series) - kind = "extremum" if direction else None - if not direction: - direction = detect_level_threshold_signal(series) - kind = "level_threshold" if direction else None - if kind and direction: + if not series: + continue + slope = _compute_slope(series) + energy = band.get("energy") + + kind: Optional[str] = None + direction: Optional[str] = None + params: Optional[Dict] = None + + if extremum_cfg.get("enabled", True): + direction = detect_extremum_signal(series) + kind = "extremum" if direction else None + if not direction and level_cfg.get("enabled", True): + threshold_k = level_cfg.get("threshold_k", 2.0) + direction = detect_level_threshold_signal(series, threshold_k) + if direction: + kind, params = "level_threshold", {"threshold_k": threshold_k} + if not direction and trend_cfg.get("enabled"): + direction = detect_trend_flatten_signal( + series, + trend_cfg.get("trend_days", 10), trend_cfg.get("flatten_days", 5), + trend_cfg.get("trend_threshold_k", 1.0), trend_cfg.get("flatten_threshold_k", 0.3), + ) + if direction: + kind = "trend_flatten" + params = {k: trend_cfg.get(k) for k in ("trend_days", "flatten_days", "trend_threshold_k", "flatten_threshold_k")} + if not direction and accel_cfg.get("enabled"): + accel_days = accel_cfg.get("accel_days", 3) + accel_threshold_k = accel_cfg.get("accel_threshold_k", 1.5) + direction = detect_acceleration_signal(series, accel_days, accel_threshold_k) + if direction: + kind, params = "acceleration", {"accel_days": accel_days, "accel_threshold_k": accel_threshold_k} + if not direction and cross_cfg.get("enabled"): + sec_idx = int(cross_cfg.get("secondary_band", 1)) + if 0 <= sec_idx < len(bands) and sec_idx != i: + direction = detect_band_cross_signal(series, bands[sec_idx]["series"]) + if direction: + kind, params = "band_cross", {"secondary_band": sec_idx} + if not direction and energy_cfg.get("enabled") and energy: + threshold_k = energy_cfg.get("threshold_k", 2.0) + direction = detect_level_threshold_signal(energy, threshold_k) + if direction: + kind, params = "energy_threshold", {"threshold_k": threshold_k} + + results.append({ + "ticker": ticker, + "band_label": band["label"], + "period_low_days": band.get("period_low_days"), + "period_high_days": band.get("period_high_days"), + "signal_kind": kind, + "direction": direction, + "price_at_signal": price_at_signal, + "slope": slope[-1], + "value": series[-1], + "energy": energy[-1] if energy else None, + "ridge_period_days": None, + "params_json": json.dumps(params) if params else None, + }) + + # Ridge — one row per ticker (ssq only), not per band + if method == "ssq" and decomposed.get("ridge_period_days"): + ridge_series = [v for v in decomposed["ridge_period_days"] if v is not None] + if ridge_series: + ridge_kind = None + ridge_direction = None + ridge_params = None + if ridge_cfg.get("enabled"): + threshold_k = ridge_cfg.get("threshold_k", 2.0) + ridge_direction = detect_level_threshold_signal(ridge_series, threshold_k) + if ridge_direction: + ridge_kind, ridge_params = "ridge_shift", {"threshold_k": threshold_k} results.append({ "ticker": ticker, - "band_label": band["label"], - "period_low_days": band.get("period_low_days"), - "period_high_days": band.get("period_high_days"), - "signal_kind": kind, - "direction": direction, + "band_label": "ridge", + "period_low_days": None, + "period_high_days": None, + "signal_kind": ridge_kind, + "direction": ridge_direction, "price_at_signal": price_at_signal, + "slope": None, + "value": None, + "energy": None, + "ridge_period_days": ridge_series[-1], + "params_json": json.dumps(ridge_params) if ridge_params else None, }) except Exception: continue # one bad ticker must not abort the whole scan diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index 3fb0b98..a896a56 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -84,7 +84,7 @@ export default function ChatWidget() { refresh_context: pendingRefresh, }) setPendingRefresh(false) - setMessages(prev => [...prev, { role: 'assistant', content: res.reply }]) + setMessages(prev => [...prev, { role: 'assistant', content: res.reply, trade_proposal: res.trade_proposal }]) } catch (e: any) { const msg = e?.response?.data?.detail ?? 'Erreur — vérifie la clé API OpenAI dans Configuration.' setMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${msg}` }]) @@ -164,13 +164,18 @@ export default function ChatWidget() { )} {messages.map((m, i) => ( -
+
{m.content}
+ {m.trade_proposal && ( +
+ ✅ Idée ajoutée → Trade Ideas ({m.trade_proposal.title}) +
+ )}
))} {isPending && ( diff --git a/frontend/src/components/TradeIdeas.tsx b/frontend/src/components/TradeIdeas.tsx index dbaab4d..da47ec3 100644 --- a/frontend/src/components/TradeIdeas.tsx +++ b/frontend/src/components/TradeIdeas.tsx @@ -2,11 +2,11 @@ import { useState, useMemo, useEffect, Fragment } from 'react' import { useAllPatterns, useLastScores, useScorePatterns, useAiStatus, usePortfolioPositions, useTradeMtm, useRiskProfiles, useMacroRegime, useAddPosition, - useConfig, + useConfig, useAiTradeProposals, useConfirmAiTradeProposal, useRejectAiTradeProposal, } from '../hooks/useApi' import { Target, Brain, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, - LayoutGrid, List, Terminal, + LayoutGrid, List, Terminal, Bot, Check, X as XIcon, } from 'lucide-react' import clsx from 'clsx' import { format } from 'date-fns' @@ -683,6 +683,72 @@ export function TradeRow({ item, onAdd, macroInfo, addedInfo, profiles, rank }: ) } +// ── AI-proposed trades — awaiting explicit user confirmation ────────────────── +// Created by the chat widget's propose_trade tool call. Deliberately kept +// separate from TradeCard/TradeItem (those are derived from scored patterns — +// grafting an AI-sourced item onto that shape would be fragile). +function AiProposedTradesSection() { + const { data: proposals } = useAiTradeProposals('pending') + const { mutate: confirmProposal, isPending: confirming } = useConfirmAiTradeProposal() + const { mutate: rejectProposal, isPending: rejecting } = useRejectAiTradeProposal() + const [pendingId, setPendingId] = useState(null) + const [error, setError] = useState(null) + + if (!proposals || proposals.length === 0) return null + + const handleConfirm = (id: string) => { + setPendingId(id); setError(null) + confirmProposal(id, { + onError: (e: any) => setError(e?.response?.data?.detail ?? 'Erreur lors de la confirmation.'), + onSettled: () => setPendingId(null), + }) + } + const handleReject = (id: string) => { + setPendingId(id) + rejectProposal(id, { onSettled: () => setPendingId(null) }) + } + + return ( +
+

+ Idées proposées par l'IA + ({proposals.length} en attente) +

+ {error &&
{error}
} +
+ {proposals.map(p => ( +
+
+
+
{p.title}
+
{p.underlying} · {p.strategy} · {p.capital_invested.toLocaleString('fr-FR')} €
+
+ 🤖 IA +
+ {p.rationale &&
{p.rationale}
} +
+ + +
+
+ ))} +
+
+ ) +} + // ── Self-contained Trade Ideas Tab ──────────────────────────────────────────── export function TradeIdeasTab() { const { data: allPatternsData } = useAllPatterns() @@ -862,6 +928,8 @@ export function TradeIdeasTab() { return (
+ + {/* Toolbar */}
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 047b4ed..8a541dc 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1429,7 +1429,7 @@ export function useScoreText() { // ── AI Chat widget — free-form, read-only, context-aware conversation ──────── -export interface ChatMessage { role: 'user' | 'assistant'; content: string; created_at?: string } +export interface ChatMessage { role: 'user' | 'assistant'; content: string; created_at?: string; trade_proposal?: TradeProposalRef | null } export const useChatContextBlocks = () => useQuery({ @@ -1446,13 +1446,61 @@ export const useChatHistory = (sessionId: string) => staleTime: Infinity, }) +export interface TradeProposalRef { id: string; title?: string; underlying?: string; strategy?: string } + export const useSendChatMessage = () => useMutation({ mutationFn: (body: { session_id: string; message: string; enabled_blocks?: string[]; refresh_context?: boolean }) => - api.post('/ai-chat/', body).then(r => r.data as { reply: string; blocks_included: string[] }), + api.post('/ai-chat/', body).then(r => r.data as { reply: string; blocks_included: string[]; trade_proposal: TradeProposalRef | null }), }) export const useClearChatSession = () => useMutation({ mutationFn: (sessionId: string) => api.post('/ai-chat/clear', { session_id: sessionId }).then(r => r.data), }) + +// ── AI Chat widget — trade proposals (pending confirmation) ────────────────── + +export interface AiTradeProposal { + id: string + session_id: string + created_at: string + status: 'pending' | 'confirmed' | 'rejected' + title: string + underlying: string + strategy: string + asset_class: string + expiry_days: number + capital_invested: number + legs: Array<{ strike: number; option_type: string; quantity: number; position: string }> + geo_trigger: string + rationale: string + portfolio_id?: string | null +} + +export const useAiTradeProposals = (status: string = 'pending') => + useQuery({ + queryKey: ['ai-trade-proposals', status], + queryFn: () => api.get('/ai-chat/trade-proposals', { params: { status } }).then(r => r.data.proposals as AiTradeProposal[]), + refetchInterval: 30000, + }) + +export const useConfirmAiTradeProposal = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (proposalId: string) => api.post(`/ai-chat/trade-proposals/${proposalId}/confirm`).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['ai-trade-proposals'] }) + qc.invalidateQueries({ queryKey: ['portfolio'] }) + qc.invalidateQueries({ queryKey: ['portfolio-summary'] }) + }, + }) +} + +export const useRejectAiTradeProposal = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (proposalId: string) => api.post(`/ai-chat/trade-proposals/${proposalId}/reject`).then(r => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['ai-trade-proposals'] }), + }) +} diff --git a/frontend/src/pages/AIDesks.tsx b/frontend/src/pages/AIDesks.tsx index 4d8aaad..393fa5f 100644 --- a/frontend/src/pages/AIDesks.tsx +++ b/frontend/src/pages/AIDesks.tsx @@ -5,9 +5,9 @@ import clsx from 'clsx' // ── Types ───────────────────────────────────────────────────────────────────── interface SignalParam { - type: 'int' | 'float' | 'pairs' + type: 'int' | 'float' | 'pairs' | 'select' label: string - default: number | number[][] + default: number | number[][] | string min?: number max?: number options?: string[] @@ -263,6 +263,20 @@ function SignalToggle({
) } + if (p.type === 'select') { + return ( +
+ + +
+ ) + } return (