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