feat: AI Desks — configurable agent system for news/technical/eco processing

- New ai_desks table with CRUD (get_all/by_type/upsert/delete)
- ai_desks router: REST API + GET /signal-catalog (7 extensible signals)
- News Desk: semantic dedup via AI (±N days window, system_prompt hint)
- Technical Desk: 4 signal detectors driven by desk config
  (ma_cross, rsi_extreme, bb_squeeze, new_52w_extreme)
- 3 more signals in catalog ready to enable: price_gap, volume_spike, macd_crossover
- market_event_detector.py loads desk configs at runtime, falls back to legacy params
- AIDesks.tsx: full editor UI with signal toggles, param sliders, instrument multi-select
- Sidebar: Bot icon + /ai-desks route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 23:08:14 +02:00
parent 069b398d75
commit 97706dea7b
7 changed files with 1350 additions and 135 deletions

148
backend/routers/ai_desks.py Normal file
View File

@@ -0,0 +1,148 @@
"""
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",
"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",
"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",
"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)",
"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",
"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",
"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",
"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},
},
},
]
# ── Schemas ───────────────────────────────────────────────────────────────────
class AIDeskUpsert(BaseModel):
name: str
type: str
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, upsert_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")
upsert_ai_desk({**body.dict(), "name": desk["name"]})
return {"status": "updated"}
@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"}