Files
OpenFin/backend/services/ai_chat.py
2026-07-15 08:47:16 +02:00

177 lines
9.1 KiB
Python

"""
Free-form chat with GPT-4o about the current cockpit state.
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
from services.ai_analyzer import get_client
from services.ai_chat_context import assemble_context, CONTEXT_BLOCKS
SYSTEM_PROMPT_HEADER = """Tu es l'assistant IA integre au cockpit de trading OpenFin Intelligence.
Tu as acces ci-dessous a un instantane en lecture seule de la situation actuelle (portefeuille, risque geopolitique, regime macro, patterns, options/IV, indicateurs techniques, signaux ondelettes, watchlist, calendrier economique, rapports institutionnels, lecons accumulees, VaR/risque) - le meme niveau de detail que celui utilise par le cycle d'analyse automatique pour generer ses recommandations.
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.
- 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 ===
{context}
=== 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, tools: Optional[List[Dict]] = None):
"""Multi-turn variant of ai_analyzer._chat() — accepts a full message history
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")
kwargs = {
"model": model,
"messages": [{"role": "system", "content": system}] + messages,
"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
except Exception as e:
last_exc = e
err_str = str(e)
if "429" in err_str or "rate_limit" in err_str:
m = re.search(r"try again in ([\d.]+)s", err_str)
wait = float(m.group(1)) + 1.0 if m else 2 ** (attempt + 1) * 5.0
time.sleep(min(wait, 60.0))
continue
raise
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,
enabled_blocks: Optional[List[str]] = None,
refresh_context: bool = False,
) -> Dict:
from services.database import save_chat_message, get_chat_messages
enabled = enabled_blocks or CONTEXT_BLOCKS
blocks = assemble_context(enabled, session_id, refresh=refresh_context)
context_text = "\n\n".join(blocks.values())
system = SYSTEM_PROMPT_HEADER.format(context=context_text)
history = get_chat_messages(session_id)
messages = [{"role": h["role"], "content": h["content"]} for h in history]
messages.append({"role": "user", "content": message})
save_chat_message(session_id, "user", message)
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}