- 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>
149 lines
5.8 KiB
Python
149 lines
5.8 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",
|
|
"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"}
|