Files
OpenFin/backend/routers/ai_desks.py
2026-06-28 16:36:28 +02:00

218 lines
8.7 KiB
Python

"""
AI Desks — CRUD + signal catalog.
Prefix: /api/ai-desks
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/ai-desks", tags=["AI Desks"])
# ── Signal catalog (extensible) ───────────────────────────────────────────────
SIGNAL_CATALOG: List[Dict[str, Any]] = [
{
"id": "ma_cross",
"label": "Croisement de moyennes mobiles",
"description": "Détecte les croisements bullish/bearish entre deux MAs",
"desk_type": "technical",
"params": {
"pairs": {
"type": "pairs", "label": "Paires MA",
"default": [["MA50", "MA200"], ["MA50", "MA100"]],
"options": ["MA20", "MA50", "MA100", "MA200"],
},
},
},
{
"id": "rsi_extreme",
"label": "RSI extrême",
"description": "Signal quand le RSI dépasse les seuils de surachat/survente",
"desk_type": "technical",
"params": {
"period": {"type": "int", "label": "Période", "default": 14, "min": 5, "max": 50},
"oversold": {"type": "int", "label": "Survente", "default": 30, "min": 10, "max": 45},
"overbought": {"type": "int", "label": "Surachat", "default": 70, "min": 55, "max": 90},
},
},
{
"id": "bb_squeeze",
"label": "Squeeze Bollinger Bands",
"description": "Détecte quand les bandes BB se resserrent en dessous d'un seuil",
"desk_type": "technical",
"params": {
"period": {"type": "int", "label": "Période", "default": 20, "min": 10, "max": 50},
"std": {"type": "float", "label": "Std dev", "default": 2.0, "min": 1.0, "max": 3.0},
"width_threshold": {"type": "float", "label": "Seuil width", "default": 0.05, "min": 0.01, "max": 0.2},
},
},
{
"id": "new_52w_extreme",
"label": "Nouveau 52 semaines extrême",
"description": "Nouveau plus haut ou plus bas sur 52 semaines (avec buffer)",
"desk_type": "technical",
"params": {
"buffer_pct": {"type": "float", "label": "Buffer %", "default": 0.5, "min": 0.0, "max": 5.0},
},
},
{
"id": "price_gap",
"label": "Gap de prix",
"description": "Gap d'ouverture significatif par rapport à la clôture précédente",
"desk_type": "technical",
"params": {
"min_gap_pct": {"type": "float", "label": "Gap min %", "default": 1.5, "min": 0.5, "max": 10.0},
},
},
{
"id": "volume_spike",
"label": "Spike de volume",
"description": "Volume anormalement élevé par rapport à la moyenne mobile",
"desk_type": "technical",
"params": {
"ma_period": {"type": "int", "label": "Période MA vol", "default": 20, "min": 5, "max": 50},
"spike_factor": {"type": "float", "label": "Facteur spike", "default": 2.5, "min": 1.5, "max": 10.0},
},
},
{
"id": "macd_crossover",
"label": "Croisement MACD",
"description": "Croisement de la ligne MACD avec la ligne signal",
"desk_type": "technical",
"params": {
"fast": {"type": "int", "label": "EMA rapide", "default": 12, "min": 5, "max": 30},
"slow": {"type": "int", "label": "EMA lente", "default": 26, "min": 15, "max": 60},
"signal": {"type": "int", "label": "Signal", "default": 9, "min": 3, "max": 20},
},
},
# ── Sentiment signals ───────────────────────────────────────────────────
{
"id": "vix_level",
"label": "VIX — franchissement de seuil",
"description": "Signal quand le VIX franchit un niveau clé (20, 25, 30, 35, 45)",
"desk_type": "sentiment",
"params": {
"thresholds": {"type": "multi_int", "label": "Niveaux VIX", "default": [20, 25, 30, 35, 45]},
},
},
{
"id": "vix_spike",
"label": "VIX — spike journalier",
"description": "VIX progresse de X% ou plus en une séance",
"desk_type": "sentiment",
"params": {
"min_pct_change": {"type": "float", "label": "Variation min %", "default": 15.0, "min": 5.0, "max": 50.0},
},
},
{
"id": "vix_term_structure",
"label": "VIX — inversion structure de terme",
"description": "VIX9D > VIX (peur concentrée court terme)",
"desk_type": "sentiment",
"params": {
"inversion_threshold": {"type": "float", "label": "Ratio VIX9D/VIX min", "default": 1.05, "min": 1.0, "max": 1.5},
},
},
{
"id": "vvix_extreme",
"label": "VVIX extrême",
"description": "VVIX (volatilité du VIX) dépasse un seuil",
"desk_type": "sentiment",
"params": {
"threshold": {"type": "float", "label": "Seuil VVIX", "default": 100.0, "min": 80.0, "max": 150.0},
},
},
{
"id": "skew_extreme",
"label": "CBOE SKEW extrême",
"description": "SKEW index très haut (protection tail coûteuse) ou très bas (complaisance)",
"desk_type": "sentiment",
"params": {
"low_threshold": {"type": "float", "label": "Seuil bas", "default": 120.0, "min": 100.0, "max": 130.0},
"high_threshold": {"type": "float", "label": "Seuil haut", "default": 145.0, "min": 135.0, "max": 170.0},
},
},
]
# ── Schemas ───────────────────────────────────────────────────────────────────
DESK_TYPES = {"news", "fundamental", "technical", "eco", "report", "sentiment"}
class AIDeskUpsert(BaseModel):
name: str
type: str # one of DESK_TYPES
active: bool = True
system_prompt: str = ""
instruments: List[str] = []
config: Dict[str, Any] = {}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("")
def list_desks() -> List[Dict[str, Any]]:
from services.database import get_all_ai_desks
return get_all_ai_desks()
@router.get("/signal-catalog")
def get_signal_catalog() -> List[Dict[str, Any]]:
return SIGNAL_CATALOG
@router.get("/{desk_id}")
def get_desk(desk_id: int) -> Dict[str, Any]:
from services.database import get_all_ai_desks
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
return desk
@router.post("", status_code=201)
def create_desk(body: AIDeskUpsert) -> Dict[str, Any]:
from services.database import upsert_ai_desk
new_id = upsert_ai_desk(body.dict())
return {"id": new_id, "status": "created"}
@router.put("/{desk_id}")
def update_desk(desk_id: int, body: AIDeskUpsert) -> Dict[str, Any]:
from services.database import get_all_ai_desks, update_ai_desk_by_id
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
update_ai_desk_by_id(desk_id, body.dict())
return {"status": "updated"}
@router.patch("/{desk_id}/toggle")
def toggle_desk_active(desk_id: int) -> Dict[str, Any]:
from services.database import get_all_ai_desks, update_ai_desk_by_id
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
desk["active"] = not desk["active"]
update_ai_desk_by_id(desk_id, desk)
return {"status": "toggled", "active": desk["active"]}
@router.delete("/{desk_id}")
def delete_desk(desk_id: int) -> Dict[str, Any]:
from services.database import get_all_ai_desks, delete_ai_desk
desks = get_all_ai_desks()
desk = next((d for d in desks if d["id"] == desk_id), None)
if not desk:
raise HTTPException(404, f"Desk {desk_id} not found")
delete_ai_desk(desk["name"])
return {"status": "deleted"}