feat: chatbot
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'] }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user