feat: chatbot

This commit is contained in:
OpenSquared
2026-07-15 08:47:16 +02:00
parent da536e2638
commit ce9c0b53a9
11 changed files with 837 additions and 64 deletions

View File

@@ -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

View File

@@ -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"}

View File

@@ -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",

View File

@@ -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}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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() {
</div>
)}
{messages.map((m, i) => (
<div key={i} className={clsx('flex', m.role === 'user' ? 'justify-end' : 'justify-start')}>
<div key={i} className={clsx('flex flex-col', m.role === 'user' ? 'items-end' : 'items-start')}>
<div className={clsx(
'max-w-[85%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed',
m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-dark-700/80 border border-slate-700/40 text-slate-200',
)}>
{m.content}
</div>
{m.trade_proposal && (
<div className="mt-1 text-[10px] px-2 py-1 rounded bg-emerald-900/30 border border-emerald-700/40 text-emerald-400">
Idée ajoutée Trade Ideas ({m.trade_proposal.title})
</div>
)}
</div>
))}
{isPending && (

View File

@@ -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<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="card border-blue-500/30">
<h3 className="section-title flex items-center gap-1.5 mb-3">
<Bot className="w-3.5 h-3.5 text-blue-400" /> Idées proposées par l'IA
<span className="text-slate-600 font-normal">({proposals.length} en attente)</span>
</h3>
{error && <div className="text-xs text-red-400 mb-2">{error}</div>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{proposals.map(p => (
<div key={p.id} className="rounded-lg border border-slate-700/40 bg-dark-900/60 p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-white">{p.title}</div>
<div className="text-[11px] text-slate-500">{p.underlying} · {p.strategy} · {p.capital_invested.toLocaleString('fr-FR')} €</div>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 shrink-0">🤖 IA</span>
</div>
{p.rationale && <div className="text-[11px] text-slate-400 leading-snug">{p.rationale}</div>}
<div className="flex items-center gap-2 pt-1">
<button
onClick={() => handleConfirm(p.id)}
disabled={confirming && pendingId === p.id}
className="flex-1 flex items-center justify-center gap-1 text-xs px-2 py-1.5 rounded bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 disabled:opacity-40 transition-colors"
>
<Check className="w-3 h-3" /> Confirmer
</button>
<button
onClick={() => handleReject(p.id)}
disabled={rejecting && pendingId === p.id}
className="flex-1 flex items-center justify-center gap-1 text-xs px-2 py-1.5 rounded bg-slate-700/40 text-slate-400 hover:bg-slate-700/60 disabled:opacity-40 transition-colors"
>
<XIcon className="w-3 h-3" /> Rejeter
</button>
</div>
</div>
))}
</div>
</div>
)
}
// ── Self-contained Trade Ideas Tab ────────────────────────────────────────────
export function TradeIdeasTab() {
const { data: allPatternsData } = useAllPatterns()
@@ -862,6 +928,8 @@ export function TradeIdeasTab() {
return (
<div className="space-y-4">
<AiProposedTradesSection />
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">

View File

@@ -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'] }),
})
}

View File

@@ -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({
</div>
)
}
if (p.type === 'select') {
return (
<div key={key} className="flex items-center gap-3">
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>
<select
value={value[key] ?? p.default}
onChange={e => update(key, e.target.value)}
className="w-32 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
>
{(p.options ?? []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
</div>
)
}
return (
<div key={key} className="flex items-center gap-3">
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>