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") _log.info("[Startup] Instrument models seeded")
except Exception as _e: except Exception as _e:
_log.warning(f"[Startup] Instrument models seed failed: {_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 # Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline
# Start auto-cycle scheduler if enabled # Start auto-cycle scheduler if enabled
from services.auto_cycle import start_scheduler 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. Free-form chat with GPT-4o about the current cockpit state.
No function-calling — this endpoint can never trigger an action. 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 from typing import List, Optional
@@ -58,3 +60,50 @@ def clear_session(body: ClearBody):
clear_chat_session(body.session_id) clear_chat_session(body.session_id)
clear_context_cache(body.session_id) clear_context_cache(body.session_id)
return {"cleared": 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}, "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 ─────────────────────────────────────────────────── # ── Sentiment signals ───────────────────────────────────────────────────
{ {
"id": "vix_level", "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 The only action the model can trigger is `propose_trade` — even then, it never
call physically cannot trigger any action (no trade, no cycle, no DB write touches the real portfolio: the tool call just writes a 'pending' row to
beyond persisting the conversation itself). The system prompt also tells the ai_trade_proposals. The user has to explicitly confirm from the Trade Ideas UI
model explicitly not to claim it can act, so it doesn't mislead the user. (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 re
import time import time
from typing import Dict, List, Optional 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 : 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. - 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. - 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 === === CONTEXTE ACTUEL ===
@@ -26,11 +30,55 @@ REGLES IMPORTANTES :
=== FIN DU CONTEXTE === === 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 """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 instead of a single system+user pair, and optionally OpenAI tool schemas.
independent so it never risks the well-tested cycle-facing _chat().""" 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() client = get_client()
if not client: if not client:
raise RuntimeError("OpenAI API key not configured") 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, "temperature": 0.4,
"max_tokens": max_tokens, "max_tokens": max_tokens,
} }
if tools:
kwargs["tools"] = tools
last_exc: Optional[Exception] = None last_exc: Optional[Exception] = None
for attempt in range(4): for attempt in range(4):
try: try:
resp = client.chat.completions.create(**kwargs) resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or "" return resp.choices[0].message
except Exception as e: except Exception as e:
last_exc = e last_exc = e
err_str = str(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] 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( def send_chat_message(
session_id: str, session_id: str,
message: str, message: str,
@@ -77,7 +148,29 @@ def send_chat_message(
messages.append({"role": "user", "content": message}) messages.append({"role": "user", "content": message})
save_chat_message(session_id, "user", 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: def _block_wavelet_signals() -> str:
from services.database import get_latest_wavelet_signals from services.database import get_latest_wavelet_state
signals = get_latest_wavelet_signals() rows = get_latest_wavelet_state()
if not signals: if not rows:
return "## WAVELET SIGNALS\nNo wavelet signal detected yet (computed each auto-cycle)." return "## WAVELET SIGNALS\nNo wavelet state computed yet (computed each auto-cycle for the watchlist instruments)."
lines = ["## WAVELET SIGNALS (watchlist, latest cycle scan)"]
for s in signals[:20]: by_ticker: Dict[str, List[Dict]] = {}
lines.append(f"- {s['ticker']}: band {s['band_label']} · {s['signal_kind']} · {s['direction']} @ {s.get('price_at_signal')}") 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) return "\n".join(lines)

View File

@@ -143,6 +143,32 @@ def init_db():
content TEXT NOT NULL, content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')) 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: try:
c.execute(_sql) 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)") c.execute("CREATE INDEX IF NOT EXISTS idx_wws_ticker_date ON wavelet_watchlist_signals(ticker, computed_at DESC)")
except Exception: except Exception:
pass pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_atp_session_status ON ai_trade_proposals(session_id, status)")
except Exception:
pass
try: try:
c.execute("CREATE INDEX IF NOT EXISTS idx_chat_session_date ON ai_chat_messages(session_id, created_at)") 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: for s in signals:
conn.execute( conn.execute(
"INSERT INTO wavelet_watchlist_signals " "INSERT INTO wavelet_watchlist_signals "
"(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal) " "(run_id, ticker, band_label, period_low_days, period_high_days, signal_kind, direction, price_at_signal, "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "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"), (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.commit()
conn.close() 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]: 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() conn = get_conn()
rows = conn.execute( rows = conn.execute(
"""SELECT w.* FROM wavelet_watchlist_signals w "SELECT * FROM wavelet_watchlist_signals WHERE run_id=? AND signal_kind IS NOT NULL ORDER BY computed_at DESC",
INNER JOIN ( (run_id,),
SELECT ticker, MAX(computed_at) AS max_computed_at ).fetchall()
FROM wavelet_watchlist_signals GROUP BY ticker conn.close()
) latest ON w.ticker = latest.ticker AND w.computed_at = latest.max_computed_at return [dict(r) for r in rows]
ORDER BY w.computed_at DESC"""
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() ).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -3255,6 +3311,72 @@ def clear_chat_session(session_id: str) -> None:
conn.close() 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 ─────────────────────────────────────────────────────────────── # ── System Logs ───────────────────────────────────────────────────────────────
def log_system_event( 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) 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: def upsert_ai_desk(desk: Dict[str, Any]) -> int:
conn = get_conn() conn = get_conn()
try: try:

View File

@@ -2,17 +2,53 @@
Automated wavelet signal detection for the watchlist — run once per cycle. Automated wavelet signal detection for the watchlist — run once per cycle.
Ported (Python subset) from the trigger-signal detectors in Ported (Python subset) from the trigger-signal detectors in
c:\\DataS\\InstrumentSimulator\\frontend\\src\\main.tsx (lines 180-280, TypeScript). c:\\DataS\\InstrumentSimulator\\frontend\\src\\main.tsx / frontend/src/lib/waveletTrade.ts.
Only `extremum` and `level_threshold` are ported here: they're self-contained All 7 trigger kinds from the interactive Wavelets Simulation page are now
(single curve, no secondary curve/config needed) and robust enough for an available here: extremum, level_threshold, trend_flatten, acceleration,
unattended scan. The richer configurable trigger set (trend_flatten, band_cross, ridge_shift (ssq only), energy_threshold (ssq only).
acceleration, band_cross, ridge_shift, energy_threshold) stays exclusive to the
interactive Wavelets Simulation page (frontend/src/lib/waveletTrade.ts), where a Parameters (engine + per-signal enable/thresholds) come from the "Technical
user picks and tunes them explicitly. 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 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]: def _build_extremum_signal(series: List[float], direction: str) -> List[bool]:
n = len(series) n = len(series)
raw = [False] * n raw = [False] * n
@@ -52,6 +88,88 @@ def _build_level_threshold_signal(series: List[float], direction: str, threshold
return signal 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]: def detect_extremum_signal(series: List[float]) -> Optional[str]:
"""Returns 'up' (confirmed peak) or 'down' (confirmed trough) if the most """Returns 'up' (confirmed peak) or 'down' (confirmed trough) if the most
recent point is a signal, else None.""" 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 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 """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 instrument. Every (ticker, band) gets a row every cycle — current slope/
trailing ~60 output points are computed (not the whole history) — this scan value/energy state always, plus signal_kind/direction/params_json when one
only needs to know about *today*, unlike the interactive Simulation page's of the enabled trigger kinds fires on the most recent point (first match
full-range backtest.""" 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.database import get_instruments_watchlist
from services.data_fetcher import get_historical from services.data_fetcher import get_historical
from services.wavelet_engine import rolling_causal_bands, rolling_causal_bands_ssq 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] = [] results: List[Dict] = []
decomposer = rolling_causal_bands_ssq if method == "ssq" else rolling_causal_bands 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"]: if not decomposed["dates"]:
continue continue
price_at_signal = decomposed["original"][-1] price_at_signal = decomposed["original"][-1]
bands = decomposed["bands"]
for band in decomposed["bands"]: for i, band in enumerate(bands):
series = band["series"] series = band["series"]
direction = detect_extremum_signal(series) if not series:
kind = "extremum" if direction else None continue
if not direction: slope = _compute_slope(series)
direction = detect_level_threshold_signal(series) energy = band.get("energy")
kind = "level_threshold" if direction else None
if kind and direction: 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({ results.append({
"ticker": ticker, "ticker": ticker,
"band_label": band["label"], "band_label": "ridge",
"period_low_days": band.get("period_low_days"), "period_low_days": None,
"period_high_days": band.get("period_high_days"), "period_high_days": None,
"signal_kind": kind, "signal_kind": ridge_kind,
"direction": direction, "direction": ridge_direction,
"price_at_signal": price_at_signal, "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: except Exception:
continue # one bad ticker must not abort the whole scan continue # one bad ticker must not abort the whole scan

View File

@@ -84,7 +84,7 @@ export default function ChatWidget() {
refresh_context: pendingRefresh, refresh_context: pendingRefresh,
}) })
setPendingRefresh(false) 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) { } catch (e: any) {
const msg = e?.response?.data?.detail ?? 'Erreur — vérifie la clé API OpenAI dans Configuration.' const msg = e?.response?.data?.detail ?? 'Erreur — vérifie la clé API OpenAI dans Configuration.'
setMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${msg}` }]) setMessages(prev => [...prev, { role: 'assistant', content: `⚠️ ${msg}` }])
@@ -164,13 +164,18 @@ export default function ChatWidget() {
</div> </div>
)} )}
{messages.map((m, i) => ( {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( <div className={clsx(
'max-w-[85%] rounded-lg px-3 py-2 text-xs whitespace-pre-wrap leading-relaxed', '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.role === 'user' ? 'bg-blue-600 text-white' : 'bg-dark-700/80 border border-slate-700/40 text-slate-200',
)}> )}>
{m.content} {m.content}
</div> </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> </div>
))} ))}
{isPending && ( {isPending && (

View File

@@ -2,11 +2,11 @@ import { useState, useMemo, useEffect, Fragment } from 'react'
import { import {
useAllPatterns, useLastScores, useScorePatterns, useAiStatus, useAllPatterns, useLastScores, useScorePatterns, useAiStatus,
usePortfolioPositions, useTradeMtm, useRiskProfiles, useMacroRegime, useAddPosition, usePortfolioPositions, useTradeMtm, useRiskProfiles, useMacroRegime, useAddPosition,
useConfig, useConfig, useAiTradeProposals, useConfirmAiTradeProposal, useRejectAiTradeProposal,
} from '../hooks/useApi' } from '../hooks/useApi'
import { import {
Target, Brain, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2, Target, Brain, Plus, RefreshCw, ChevronDown, ChevronUp, CheckCircle2,
LayoutGrid, List, Terminal, LayoutGrid, List, Terminal, Bot, Check, X as XIcon,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
import { format } from 'date-fns' 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 ──────────────────────────────────────────── // ── Self-contained Trade Ideas Tab ────────────────────────────────────────────
export function TradeIdeasTab() { export function TradeIdeasTab() {
const { data: allPatternsData } = useAllPatterns() const { data: allPatternsData } = useAllPatterns()
@@ -862,6 +928,8 @@ export function TradeIdeasTab() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<AiProposedTradesSection />
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 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 ──────── // ── 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 = () => export const useChatContextBlocks = () =>
useQuery({ useQuery({
@@ -1446,13 +1446,61 @@ export const useChatHistory = (sessionId: string) =>
staleTime: Infinity, staleTime: Infinity,
}) })
export interface TradeProposalRef { id: string; title?: string; underlying?: string; strategy?: string }
export const useSendChatMessage = () => export const useSendChatMessage = () =>
useMutation({ useMutation({
mutationFn: (body: { session_id: string; message: string; enabled_blocks?: string[]; refresh_context?: boolean }) => 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 = () => export const useClearChatSession = () =>
useMutation({ useMutation({
mutationFn: (sessionId: string) => api.post('/ai-chat/clear', { session_id: sessionId }).then(r => r.data), 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 ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
interface SignalParam { interface SignalParam {
type: 'int' | 'float' | 'pairs' type: 'int' | 'float' | 'pairs' | 'select'
label: string label: string
default: number | number[][] default: number | number[][] | string
min?: number min?: number
max?: number max?: number
options?: string[] options?: string[]
@@ -263,6 +263,20 @@ function SignalToggle({
</div> </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 ( return (
<div key={key} className="flex items-center gap-3"> <div key={key} className="flex items-center gap-3">
<label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label> <label className="text-xs text-slate-400 w-28 shrink-0">{p.label}</label>