""" 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}, }, }, # ── 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", "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"}