feat: isolated cycle action — Check New Market Events
Décompose le cycle en 8 actions appelables individuellement. Action 1 implémentée : scan de 4 sources (news RSS, surprises FRED, MA crossovers yfinance, rapports institutionnels) → création de market_events avec déduplication. UI CycleActions page + sidebar link. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ from routers import specialist_desks as specialist_desks_router
|
||||
from routers import timeline as timeline_router
|
||||
from routers import instruments as instruments_router
|
||||
from routers import impact as impact_router
|
||||
from routers import cycle_actions as cycle_actions_router
|
||||
from routers import logs as logs_router
|
||||
from routers import var as var_router
|
||||
from routers import reports as reports_router
|
||||
@@ -139,6 +140,7 @@ app.include_router(specialist_desks_router.router)
|
||||
app.include_router(timeline_router.router)
|
||||
app.include_router(instruments_router.router)
|
||||
app.include_router(impact_router.router)
|
||||
app.include_router(cycle_actions_router.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
194
backend/routers/cycle_actions.py
Normal file
194
backend/routers/cycle_actions.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Isolated Cycle Actions — callable individually from UI or cron.
|
||||
|
||||
Decomposition of the full auto_cycle into 8 independent actions:
|
||||
|
||||
ACTION 1 — check-market-events ← IMPLEMENTED HERE
|
||||
Sources: RSS news + FRED eco surprises + MA crossovers + institutional reports
|
||||
Output : new market_events created in DB
|
||||
|
||||
ACTION 2 — refresh-price-data (planned)
|
||||
Sources: yfinance OHLCV for all watchlist instruments
|
||||
Output : price_data table updated
|
||||
|
||||
ACTION 3 — compute-indicators (planned)
|
||||
Sources: price_data in DB
|
||||
Output : ma20/50/100/200 + RSI + BB + ATR stored in instrument_indicators
|
||||
|
||||
ACTION 4 — evaluate-impacts (planned)
|
||||
Sources: market_events without impact scores
|
||||
Output : GPT-evaluated impact_score + affected_assets stored
|
||||
|
||||
ACTION 5 — generate-signals (planned)
|
||||
Sources: indicators + market_events + portfolio
|
||||
Output : trading signals for each instrument
|
||||
|
||||
ACTION 6 — update-regime (planned)
|
||||
Sources: signals + macro context
|
||||
Output : current macro_regime classification
|
||||
|
||||
ACTION 7 — snapshot-portfolio (planned)
|
||||
Sources: portfolio positions + current prices
|
||||
Output : risk metrics (VaR, PnL, delta) snapshot stored
|
||||
|
||||
ACTION 8 — generate-report (planned)
|
||||
Sources: all of the above
|
||||
Output : daily cycle report PDF/JSON
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/actions", tags=["Cycle Actions"])
|
||||
|
||||
|
||||
# ── Request / Response models ─────────────────────────────────────────────────
|
||||
|
||||
class CheckEventsRequest(BaseModel):
|
||||
sources: Optional[List[str]] = None # ['news','eco','technical','reports']
|
||||
news_impact_min: float = 0.55
|
||||
news_lookback_hours: int = 48
|
||||
eco_z_threshold: float = 1.5
|
||||
eco_days: int = 7
|
||||
technical_lookback_days: int = 7
|
||||
report_days: int = 7
|
||||
report_min_importance: int = 3
|
||||
|
||||
|
||||
class ActionResult(BaseModel):
|
||||
action: str
|
||||
status: str
|
||||
started_at: str
|
||||
finished_at: str
|
||||
duration_s: float
|
||||
total_created: int
|
||||
detail: Dict[str, Any]
|
||||
|
||||
|
||||
# ── In-memory run state (prevents concurrent duplicate runs) ──────────────────
|
||||
_running: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _guard(action: str) -> None:
|
||||
if _running.get(action):
|
||||
raise HTTPException(status_code=409, detail=f"Action '{action}' is already running")
|
||||
_running[action] = True
|
||||
|
||||
|
||||
def _release(action: str) -> None:
|
||||
_running.pop(action, None)
|
||||
|
||||
|
||||
# ── GET /api/actions — list all actions with status ───────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_actions():
|
||||
"""Return the catalogue of available cycle actions and their run status."""
|
||||
actions = [
|
||||
{
|
||||
"id": "check-market-events",
|
||||
"label": "Check New Market Events",
|
||||
"description": "Scans RSS news, FRED eco surprises, MA crossovers, and institutional reports to create new market_events.",
|
||||
"status": "running" if _running.get("check-market-events") else "idle",
|
||||
"implemented": True,
|
||||
},
|
||||
{"id": "refresh-price-data", "label": "Refresh Price Data", "description": "Download fresh OHLCV for all watchlist instruments.", "status": "idle", "implemented": False},
|
||||
{"id": "compute-indicators", "label": "Compute Indicators", "description": "Recalculate MA/RSI/BB/ATR for all instruments.", "status": "idle", "implemented": False},
|
||||
{"id": "evaluate-impacts", "label": "Evaluate Impacts", "description": "AI-score market_events that lack impact_score.", "status": "idle", "implemented": False},
|
||||
{"id": "generate-signals", "label": "Generate Signals", "description": "Compute trading signals from indicators + events.", "status": "idle", "implemented": False},
|
||||
{"id": "update-regime", "label": "Update Regime", "description": "Re-classify the current macro regime.", "status": "idle", "implemented": False},
|
||||
{"id": "snapshot-portfolio", "label": "Snapshot Portfolio", "description": "Store a risk snapshot (VaR, PnL, delta).", "status": "idle", "implemented": False},
|
||||
{"id": "generate-report", "label": "Generate Report", "description": "Build and persist the daily cycle report.", "status": "idle", "implemented": False},
|
||||
]
|
||||
return {"actions": actions}
|
||||
|
||||
|
||||
# ── POST /api/actions/check-market-events ─────────────────────────────────────
|
||||
|
||||
@router.post("/check-market-events", response_model=ActionResult)
|
||||
def check_market_events(req: CheckEventsRequest):
|
||||
"""
|
||||
ACTION 1 — Check New Market Events.
|
||||
|
||||
Runs synchronously (up to ~60 s with yfinance + OpenAI calls).
|
||||
Returns counts of created events per source.
|
||||
"""
|
||||
_guard("check-market-events")
|
||||
started_at = datetime.utcnow()
|
||||
|
||||
try:
|
||||
from services.market_event_detector import check_new_market_events
|
||||
result = check_new_market_events(
|
||||
sources=req.sources,
|
||||
news_impact_min=req.news_impact_min,
|
||||
news_lookback_hours=req.news_lookback_hours,
|
||||
eco_z_threshold=req.eco_z_threshold,
|
||||
eco_days=req.eco_days,
|
||||
technical_lookback_days=req.technical_lookback_days,
|
||||
report_days=req.report_days,
|
||||
report_min_importance=req.report_min_importance,
|
||||
)
|
||||
except Exception as e:
|
||||
_release("check-market-events")
|
||||
logger.error(f"[action/check-market-events] {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
_release("check-market-events")
|
||||
finished_at = datetime.utcnow()
|
||||
duration_s = (finished_at - started_at).total_seconds()
|
||||
|
||||
return ActionResult(
|
||||
action="check-market-events",
|
||||
status="success",
|
||||
started_at=started_at.isoformat(),
|
||||
finished_at=finished_at.isoformat(),
|
||||
duration_s=round(duration_s, 2),
|
||||
total_created=result.get("total_created", 0),
|
||||
detail={
|
||||
"news": result.get("news", []),
|
||||
"eco": result.get("eco", []),
|
||||
"technical": result.get("technical", []),
|
||||
"reports": result.get("reports", []),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── POST /api/actions/check-market-events/background ─────────────────────────
|
||||
|
||||
@router.post("/check-market-events/background", status_code=202)
|
||||
def check_market_events_background(
|
||||
req: CheckEventsRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
):
|
||||
"""
|
||||
Fire-and-forget version — returns 202 immediately.
|
||||
Results are only visible via logs (system_logs table or backend console).
|
||||
"""
|
||||
if _running.get("check-market-events"):
|
||||
raise HTTPException(status_code=409, detail="Action 'check-market-events' is already running")
|
||||
|
||||
def _run():
|
||||
_guard("check-market-events")
|
||||
try:
|
||||
from services.market_event_detector import check_new_market_events
|
||||
check_new_market_events(
|
||||
sources=req.sources,
|
||||
news_impact_min=req.news_impact_min,
|
||||
news_lookback_hours=req.news_lookback_hours,
|
||||
eco_z_threshold=req.eco_z_threshold,
|
||||
eco_days=req.eco_days,
|
||||
technical_lookback_days=req.technical_lookback_days,
|
||||
report_days=req.report_days,
|
||||
report_min_importance=req.report_min_importance,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[action/check-market-events/bg] {e}")
|
||||
finally:
|
||||
_release("check-market-events")
|
||||
|
||||
background_tasks.add_task(_run)
|
||||
return {"status": "accepted", "message": "check-market-events started in background"}
|
||||
520
backend/services/market_event_detector.py
Normal file
520
backend/services/market_event_detector.py
Normal file
@@ -0,0 +1,520 @@
|
||||
"""
|
||||
Isolated cycle action: Check New Market Events.
|
||||
|
||||
Scans 4 sources and creates market_events for significant findings:
|
||||
- news : geopolitical/macro news (RSS feeds, rule-scored)
|
||||
- eco : FRED economic releases with high surprise z-score
|
||||
- technical: MA50/MA100/MA200 crossovers on key instruments
|
||||
- reports : institutional reports (COT, EIA) with high importance
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Instruments monitored for technical crossovers
|
||||
WATCH_INSTRUMENTS = [
|
||||
"SPY", "QQQ", "IWM", "EEM", "EFA",
|
||||
"GLD", "SLV", "USO", "TLT", "HYG",
|
||||
"USDJPY=X", "EURUSD=X", "VXX", "NVDA", "BTC-USD",
|
||||
]
|
||||
|
||||
SUBTYPE_FROM_SERIES = {
|
||||
"UNRATE": "NFP", "PAYEMS": "NFP",
|
||||
"CPIAUCSL": "CPI", "CPILFESL": "CPI",
|
||||
"A191RL1Q225SBEA": "GDP", "GDP": "GDP",
|
||||
"FEDFUNDS": "FOMC", "DFF": "FOMC",
|
||||
"PCEPILFE": "PCE", "PCEPI": "PCE",
|
||||
"BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit",
|
||||
}
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_api_key() -> str:
|
||||
import os
|
||||
key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if not key:
|
||||
from services.database import get_config
|
||||
key = get_config("openai_api_key") or ""
|
||||
return key
|
||||
|
||||
|
||||
def _existing_event_keys() -> set:
|
||||
"""Lowercase 50-char prefix of existing market_event names for fast dedup."""
|
||||
from services.database import get_all_market_events
|
||||
return {ev["name"].lower()[:50] for ev in get_all_market_events()}
|
||||
|
||||
|
||||
def _is_dup(name: str, existing: set) -> bool:
|
||||
return name.lower()[:50] in existing
|
||||
|
||||
|
||||
def _parse_date(raw: str) -> str:
|
||||
"""Return YYYY-MM-DD from any date string; fallback to today."""
|
||||
if not raw:
|
||||
return datetime.utcnow().strftime("%Y-%m-%d")
|
||||
# ISO-like
|
||||
try:
|
||||
return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
# RFC 2822 (RSS)
|
||||
try:
|
||||
from email.utils import parsedate_to_datetime
|
||||
return parsedate_to_datetime(raw).strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ── Source 1: Geopolitical / macro news ──────────────────────────────────────
|
||||
|
||||
def _check_news(
|
||||
min_impact: float = 0.55,
|
||||
lookback_hours: int = 48,
|
||||
max_to_evaluate: int = 15,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch recent RSS news, keep those with impact_score >= threshold,
|
||||
ask GPT-4o-mini which ones deserve a permanent market_event record.
|
||||
"""
|
||||
from services.data_fetcher import fetch_geo_news
|
||||
from services.database import save_market_event
|
||||
|
||||
api_key = _get_api_key()
|
||||
if not api_key:
|
||||
logger.warning("[check_events/news] no OpenAI key — skipping")
|
||||
return []
|
||||
|
||||
try:
|
||||
all_news = fetch_geo_news()
|
||||
except Exception as e:
|
||||
logger.warning(f"[check_events/news] fetch failed: {e}")
|
||||
return []
|
||||
|
||||
cutoff_dt = datetime.utcnow() - timedelta(hours=lookback_hours)
|
||||
candidates = []
|
||||
for n in all_news:
|
||||
if (n.get("impact_score") or 0) < min_impact:
|
||||
continue
|
||||
pub_date = _parse_date(n.get("date", ""))
|
||||
try:
|
||||
if datetime.fromisoformat(pub_date) < cutoff_dt:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append(n)
|
||||
|
||||
candidates = candidates[:max_to_evaluate]
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
existing = _existing_event_keys()
|
||||
created: List[Dict] = []
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"[check_events/news] OpenAI init failed: {e}")
|
||||
return []
|
||||
|
||||
for n in candidates:
|
||||
title = n.get("title", "")
|
||||
if not title or _is_dup(title, existing):
|
||||
continue
|
||||
|
||||
prompt = f"""Tu es un analyste macro. Cette news représente-t-elle un événement marché structurant qui mérite un enregistrement permanent ?
|
||||
|
||||
TITRE: {title}
|
||||
SOURCE: {n.get('source', '')}
|
||||
DATE: {n.get('date', '')}
|
||||
RÉSUMÉ: {str(n.get('summary', ''))[:400]}
|
||||
SCORE IMPACT (règle): {n.get('impact_score', 0):.2f}
|
||||
|
||||
Réponds OUI seulement si c'est un fait avéré, pas une rumeur ou une opinion, et qu'il a un impact macro ou géopolitique mesurable.
|
||||
|
||||
FORMAT JSON STRICT:
|
||||
{{
|
||||
"qualifies": true/false,
|
||||
"reason": "une phrase",
|
||||
"name": "Nom court (≤ 60 chars)",
|
||||
"category": "geopolitical|event_calendar|fundamental|report",
|
||||
"sub_type": "ex: Conflit, Tarifs, Sanctions, OPEC+, Crise...",
|
||||
"description": "1-2 phrases analytiques",
|
||||
"affected_assets": ["SPY","GLD",...],
|
||||
"impact_score": 0.5,
|
||||
"level": "short|medium|long"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
resp = client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.1,
|
||||
max_tokens=350,
|
||||
)
|
||||
parsed = json.loads(resp.choices[0].message.content)
|
||||
except Exception as e:
|
||||
logger.debug(f"[check_events/news] AI call failed for '{title[:40]}': {e}")
|
||||
continue
|
||||
|
||||
if not parsed.get("qualifies"):
|
||||
continue
|
||||
|
||||
ev_name = (parsed.get("name") or title)[:60]
|
||||
if _is_dup(ev_name, existing):
|
||||
continue
|
||||
|
||||
ev = {
|
||||
"name": ev_name,
|
||||
"start_date": _parse_date(n.get("date", "")),
|
||||
"level": parsed.get("level", "short"),
|
||||
"category": parsed.get("category", "geopolitical"),
|
||||
"sub_type": parsed.get("sub_type", ""),
|
||||
"description": parsed.get("description", title),
|
||||
"market_impact": "",
|
||||
"affected_assets": parsed.get("affected_assets", []),
|
||||
"impact_score": float(parsed.get("impact_score", 0.6)),
|
||||
}
|
||||
try:
|
||||
save_market_event(ev)
|
||||
existing.add(ev_name.lower()[:50])
|
||||
created.append({"name": ev_name, "category": ev["category"], "date": ev["start_date"], "source": "news"})
|
||||
logger.info(f"[check_events/news] ✓ {ev_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events/news] save failed: {e}")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── Source 2: Eco calendar — FRED surprises ───────────────────────────────────
|
||||
|
||||
def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Reads economic_events table (FRED releases already stored by fred_fetcher).
|
||||
Creates market_events for releases with |z-score| >= threshold.
|
||||
"""
|
||||
from services.database import get_recent_economic_surprises, save_market_event
|
||||
|
||||
try:
|
||||
releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold)
|
||||
except Exception as e:
|
||||
logger.warning(f"[check_events/eco] query failed: {e}")
|
||||
return []
|
||||
|
||||
existing = _existing_event_keys()
|
||||
created: List[Dict] = []
|
||||
|
||||
for rel in releases:
|
||||
z = abs(rel.get("surprise_zscore") or 0)
|
||||
s_pct = rel.get("surprise_pct") or 0
|
||||
ev_date = (rel.get("event_date") or "")[:10]
|
||||
s_id = rel.get("series_id", "")
|
||||
ev_name_base = rel.get("event_name", s_id)
|
||||
direction = rel.get("surprise_direction", "neutral")
|
||||
|
||||
sign = "+" if s_pct >= 0 else ""
|
||||
ev_name = f"{ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})"
|
||||
|
||||
if _is_dup(ev_name, existing):
|
||||
continue
|
||||
|
||||
sub_type = SUBTYPE_FROM_SERIES.get(s_id, s_id[:10]) if s_id else ev_name_base[:10]
|
||||
level = "long" if z >= 3 else ("medium" if z >= 2 else "short")
|
||||
assets = rel.get("assets_impacted") or []
|
||||
if isinstance(assets, str):
|
||||
try:
|
||||
assets = json.loads(assets)
|
||||
except Exception:
|
||||
assets = []
|
||||
|
||||
ev = {
|
||||
"name": ev_name,
|
||||
"start_date": ev_date,
|
||||
"level": level,
|
||||
"category": "event_calendar",
|
||||
"sub_type": sub_type,
|
||||
"description": (
|
||||
f"Surprise {direction} {sign}{s_pct:.1f}% vs baseline "
|
||||
f"(z-score: {z:.1f}σ). "
|
||||
f"Réel: {rel.get('actual_value', '?')} {rel.get('actual_unit', '')} "
|
||||
f"/ Prévision: {rel.get('forecast_value', '?')}."
|
||||
),
|
||||
"market_impact": "",
|
||||
"affected_assets": assets,
|
||||
"impact_score": min(0.95, 0.35 + z * 0.15),
|
||||
"actual_value": str(rel.get("actual_value", "")),
|
||||
"expected_value": str(rel.get("forecast_value", "")),
|
||||
"surprise_pct": float(s_pct),
|
||||
}
|
||||
try:
|
||||
save_market_event(ev)
|
||||
existing.add(ev_name.lower()[:50])
|
||||
created.append({"name": ev_name, "category": "event_calendar", "date": ev_date, "source": "eco"})
|
||||
logger.info(f"[check_events/eco] ✓ {ev_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events/eco] save failed: {e}")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── Source 3: MA crossovers (technical) ──────────────────────────────────────
|
||||
|
||||
def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Downloads recent OHLCV for each instrument, detects MA50/MA100/MA200 crossovers
|
||||
in the last `lookback_days` days. Creates technical market_events.
|
||||
"""
|
||||
try:
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
logger.warning("[check_events/technical] yfinance/pandas not available")
|
||||
return []
|
||||
|
||||
from services.database import save_market_event
|
||||
|
||||
if instruments is None:
|
||||
instruments = WATCH_INSTRUMENTS
|
||||
|
||||
existing = _existing_event_keys()
|
||||
created: List[Dict] = []
|
||||
cutoff = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
|
||||
|
||||
for ticker in instruments:
|
||||
try:
|
||||
df = yf.download(ticker, period="1y", interval="1d", progress=False, auto_adjust=True)
|
||||
if df is None or len(df) < 210:
|
||||
continue
|
||||
|
||||
close = df["Close"].squeeze()
|
||||
df["ma50"] = close.rolling(50).mean()
|
||||
df["ma100"] = close.rolling(100).mean()
|
||||
df["ma200"] = close.rolling(200).mean()
|
||||
|
||||
recent = df.tail(lookback_days + 2)
|
||||
|
||||
# Check consecutive row pairs for crossovers
|
||||
for i in range(1, len(recent)):
|
||||
date_str = str(recent.index[i])[:10]
|
||||
if date_str < cutoff:
|
||||
continue
|
||||
|
||||
prev = recent.iloc[i - 1]
|
||||
curr = recent.iloc[i]
|
||||
|
||||
def cross(fast_prev, fast_curr, slow_prev, slow_curr):
|
||||
if any(pd.isna(v) for v in [fast_prev, fast_curr, slow_prev, slow_curr]):
|
||||
return None
|
||||
if fast_prev < slow_prev and fast_curr >= slow_curr:
|
||||
return "golden"
|
||||
if fast_prev > slow_prev and fast_curr <= slow_curr:
|
||||
return "death"
|
||||
return None
|
||||
|
||||
pairs = [
|
||||
("MA50", "MA200", prev["ma50"], curr["ma50"], prev["ma200"], curr["ma200"]),
|
||||
("MA50", "MA100", prev["ma50"], curr["ma50"], prev["ma100"], curr["ma100"]),
|
||||
]
|
||||
|
||||
for fast_lbl, slow_lbl, fp, fc, sp, sc in pairs:
|
||||
kind = cross(fp, fc, sp, sc)
|
||||
if kind is None:
|
||||
continue
|
||||
|
||||
cross_label = "Golden Cross" if kind == "golden" else "Death Cross"
|
||||
ev_name = f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})"
|
||||
|
||||
if _is_dup(ev_name, existing):
|
||||
continue
|
||||
|
||||
direction = "bullish" if kind == "golden" else "bearish"
|
||||
level = "medium" if slow_lbl == "MA200" else "short"
|
||||
|
||||
ev = {
|
||||
"name": ev_name,
|
||||
"start_date": date_str,
|
||||
"level": level,
|
||||
"category": "technical",
|
||||
"sub_type": f"{fast_lbl}/{slow_lbl} Cross",
|
||||
"description": (
|
||||
f"{cross_label} : {fast_lbl} passe {'au-dessus' if kind == 'golden' else 'en-dessous'} "
|
||||
f"de la {slow_lbl} sur {ticker}. "
|
||||
f"Signal {direction} de tendance {'long terme' if slow_lbl == 'MA200' else 'moyen terme'}."
|
||||
),
|
||||
"market_impact": f"Signal {direction} sur {ticker}",
|
||||
"affected_assets": [ticker],
|
||||
"impact_score": 0.65 if slow_lbl == "MA200" else 0.45,
|
||||
}
|
||||
try:
|
||||
save_market_event(ev)
|
||||
existing.add(ev_name.lower()[:50])
|
||||
created.append({"name": ev_name, "category": "technical", "date": date_str, "source": "technical"})
|
||||
logger.info(f"[check_events/technical] ✓ {ev_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events/technical] save failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[check_events/technical] {ticker} failed: {e}")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── Source 4: Institutional reports ──────────────────────────────────────────
|
||||
|
||||
def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Reads institutional_reports table for recent high-importance entries.
|
||||
Creates report market_events.
|
||||
"""
|
||||
from services.database import get_conn, save_market_event
|
||||
|
||||
try:
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM institutional_reports
|
||||
WHERE report_date >= ? AND importance >= ?
|
||||
ORDER BY importance DESC, report_date DESC
|
||||
LIMIT 15""",
|
||||
(cutoff, min_importance),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
reports = [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"[check_events/reports] query failed: {e}")
|
||||
return []
|
||||
|
||||
existing = _existing_event_keys()
|
||||
created: List[Dict] = []
|
||||
|
||||
for rpt in reports:
|
||||
title = rpt.get("title", "")
|
||||
rpt_type = rpt.get("report_type", "Report")
|
||||
rpt_date = (rpt.get("report_date") or "")[:10]
|
||||
|
||||
if not title or _is_dup(title, existing):
|
||||
continue
|
||||
|
||||
ev_name = title[:60]
|
||||
summary = rpt.get("ai_summary") or rpt.get("trading_implications", "")
|
||||
try:
|
||||
kp = json.loads(rpt.get("key_points_json") or "[]")
|
||||
if kp:
|
||||
summary = " ".join(kp[:2]) + " " + summary
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Derive affected assets from signal columns
|
||||
assets: List[str] = []
|
||||
for sig_col, asset_list in [
|
||||
("signal_energy", ["USO", "XOM"]),
|
||||
("signal_metals", ["GLD", "SLV"]),
|
||||
("signal_indices", ["SPY", "QQQ"]),
|
||||
("signal_forex", ["EURUSD=X", "USDJPY=X"]),
|
||||
]:
|
||||
if rpt.get(sig_col, "neutral") not in ("neutral", "", None):
|
||||
assets.extend(asset_list)
|
||||
|
||||
ev = {
|
||||
"name": ev_name,
|
||||
"start_date": rpt_date,
|
||||
"level": "medium" if rpt.get("importance", 2) >= 4 else "short",
|
||||
"category": "report",
|
||||
"sub_type": rpt_type.upper(),
|
||||
"description": summary[:500] or f"Rapport {rpt_type} du {rpt_date}.",
|
||||
"market_impact": rpt.get("trading_implications", ""),
|
||||
"affected_assets": list(set(assets)),
|
||||
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
|
||||
}
|
||||
try:
|
||||
save_market_event(ev)
|
||||
existing.add(ev_name.lower()[:50])
|
||||
created.append({"name": ev_name, "category": "report", "date": rpt_date, "source": "reports"})
|
||||
logger.info(f"[check_events/reports] ✓ {ev_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events/reports] save failed: {e}")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── Main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
def check_new_market_events(
|
||||
sources: Optional[List[str]] = None,
|
||||
news_impact_min: float = 0.55,
|
||||
news_lookback_hours: int = 48,
|
||||
eco_z_threshold: float = 1.5,
|
||||
eco_days: int = 7,
|
||||
technical_lookback_days: int = 7,
|
||||
report_days: int = 7,
|
||||
report_min_importance: int = 3,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Isolated cycle action — scans all (or selected) sources and creates
|
||||
market_events for significant findings.
|
||||
|
||||
sources: subset of ['news', 'eco', 'technical', 'reports']
|
||||
default = all four
|
||||
"""
|
||||
if sources is None:
|
||||
sources = ["news", "eco", "technical", "reports"]
|
||||
|
||||
results: Dict[str, Any] = {
|
||||
"news": [],
|
||||
"eco": [],
|
||||
"technical": [],
|
||||
"reports": [],
|
||||
"total_created": 0,
|
||||
"ran_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
if "news" in sources:
|
||||
try:
|
||||
results["news"] = _check_news(
|
||||
min_impact=news_impact_min,
|
||||
lookback_hours=news_lookback_hours,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events] news source error: {e}")
|
||||
|
||||
if "eco" in sources:
|
||||
try:
|
||||
results["eco"] = _check_eco(
|
||||
z_threshold=eco_z_threshold,
|
||||
days=eco_days,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events] eco source error: {e}")
|
||||
|
||||
if "technical" in sources:
|
||||
try:
|
||||
results["technical"] = _check_technical(
|
||||
lookback_days=technical_lookback_days,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events] technical source error: {e}")
|
||||
|
||||
if "reports" in sources:
|
||||
try:
|
||||
results["reports"] = _check_reports(
|
||||
days=report_days,
|
||||
min_importance=report_min_importance,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[check_events] reports source error: {e}")
|
||||
|
||||
results["total_created"] = sum(
|
||||
len(results[s]) for s in ["news", "eco", "technical", "reports"]
|
||||
)
|
||||
logger.info(
|
||||
f"[check_events] Done — {results['total_created']} new events: "
|
||||
f"news={len(results['news'])} eco={len(results['eco'])} "
|
||||
f"technical={len(results['technical'])} reports={len(results['reports'])}"
|
||||
)
|
||||
return results
|
||||
@@ -27,6 +27,7 @@ import Timeline from './pages/Timeline'
|
||||
import ExternalSnapshot from './pages/ExternalSnapshot'
|
||||
import InstrumentDashboard from './pages/InstrumentDashboard'
|
||||
import ImpactMonitor from './pages/ImpactMonitor'
|
||||
import CycleActions from './pages/CycleActions'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useCycleWatcher } from './hooks/useApi'
|
||||
|
||||
@@ -71,6 +72,7 @@ export default function App() {
|
||||
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
|
||||
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
|
||||
<Route path="/impact" element={<ImpactMonitor />} />
|
||||
<Route path="/cycle-actions" element={<CycleActions />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair
|
||||
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle
|
||||
} from 'lucide-react'
|
||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||
import clsx from 'clsx'
|
||||
@@ -29,6 +29,7 @@ const nav = [
|
||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
|
||||
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
|
||||
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
|
||||
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
||||
|
||||
323
frontend/src/pages/CycleActions.tsx
Normal file
323
frontend/src/pages/CycleActions.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Play, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
|
||||
const API = 'http://localhost:8000'
|
||||
|
||||
interface ActionDef {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
status: 'idle' | 'running'
|
||||
implemented: boolean
|
||||
}
|
||||
|
||||
interface CreatedEvent {
|
||||
name: string
|
||||
category: string
|
||||
date: string
|
||||
source: string
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
action: string
|
||||
status: string
|
||||
started_at: string
|
||||
finished_at: string
|
||||
duration_s: number
|
||||
total_created: number
|
||||
detail: {
|
||||
news: CreatedEvent[]
|
||||
eco: CreatedEvent[]
|
||||
technical: CreatedEvent[]
|
||||
reports: CreatedEvent[]
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
news: 'News',
|
||||
eco: 'Éco. FRED',
|
||||
technical: 'Technique',
|
||||
reports: 'Reports',
|
||||
}
|
||||
|
||||
const CAT_COLORS: Record<string, string> = {
|
||||
event_calendar: 'text-amber-400',
|
||||
geopolitical: 'text-red-400',
|
||||
fundamental: 'text-emerald-400',
|
||||
report: 'text-blue-400',
|
||||
sentiment: 'text-purple-400',
|
||||
technical: 'text-cyan-400',
|
||||
}
|
||||
|
||||
// ── Check Market Events config panel ────────────────────────────────────────
|
||||
|
||||
interface CheckEventsConfig {
|
||||
sources: string[]
|
||||
news_impact_min: number
|
||||
news_lookback_hours: number
|
||||
eco_z_threshold: number
|
||||
eco_days: number
|
||||
technical_lookback_days: number
|
||||
report_days: number
|
||||
report_min_importance: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: CheckEventsConfig = {
|
||||
sources: ['news', 'eco', 'technical', 'reports'],
|
||||
news_impact_min: 0.55,
|
||||
news_lookback_hours: 48,
|
||||
eco_z_threshold: 1.5,
|
||||
eco_days: 7,
|
||||
technical_lookback_days: 7,
|
||||
report_days: 7,
|
||||
report_min_importance: 3,
|
||||
}
|
||||
|
||||
function CheckEventsPanel() {
|
||||
const [config, setConfig] = useState<CheckEventsConfig>(DEFAULT_CONFIG)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [result, setResult] = useState<ActionResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
|
||||
const toggleSource = (src: string) => {
|
||||
setConfig(c => ({
|
||||
...c,
|
||||
sources: c.sources.includes(src)
|
||||
? c.sources.filter(s => s !== src)
|
||||
: [...c.sources, src],
|
||||
}))
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true)
|
||||
setResult(null)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API}/api/actions/check-market-events`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || res.statusText)
|
||||
}
|
||||
const data: ActionResult = await res.json()
|
||||
setResult(data)
|
||||
setExpanded(true)
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allEvents = result
|
||||
? [...(result.detail.news || []), ...(result.detail.eco || []),
|
||||
...(result.detail.technical || []), ...(result.detail.reports || [])]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800/60 border border-slate-700/40 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span className="text-slate-100 font-semibold text-sm">Check New Market Events</span>
|
||||
<span className="text-xs text-slate-500 ml-1">
|
||||
Scans news · FRED · MA crossovers · rapports institutionnels
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowConfig(x => !x)}
|
||||
className="text-xs text-slate-400 hover:text-slate-200 border border-slate-700 rounded px-2 py-1"
|
||||
>
|
||||
{showConfig ? 'Masquer config' : 'Config'}
|
||||
</button>
|
||||
<button
|
||||
disabled={running || config.sources.length === 0}
|
||||
onClick={run}
|
||||
className="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-700
|
||||
disabled:text-slate-500 text-white text-sm font-medium rounded-lg px-4 py-2 transition-colors"
|
||||
>
|
||||
{running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{running ? 'En cours...' : 'Lancer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Config panel */}
|
||||
{showConfig && (
|
||||
<div className="px-5 pb-4 border-t border-slate-700/30 pt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
{/* Sources */}
|
||||
<div>
|
||||
<div className="text-slate-400 mb-2 text-xs uppercase tracking-wide">Sources actives</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['news', 'eco', 'technical', 'reports'].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => toggleSource(s)}
|
||||
className={`px-3 py-1 rounded-full text-xs border transition-colors ${
|
||||
config.sources.includes(s)
|
||||
? 'bg-emerald-600/30 border-emerald-500/50 text-emerald-300'
|
||||
: 'border-slate-700 text-slate-500 hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{SOURCE_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thresholds */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-400">News impact min</span>
|
||||
<input type="number" step="0.05" min="0" max="1"
|
||||
value={config.news_impact_min}
|
||||
onChange={e => setConfig(c => ({ ...c, news_impact_min: +e.target.value }))}
|
||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-400">News lookback (h)</span>
|
||||
<input type="number" step="12" min="6" max="168"
|
||||
value={config.news_lookback_hours}
|
||||
onChange={e => setConfig(c => ({ ...c, news_lookback_hours: +e.target.value }))}
|
||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-400">Éco z-score min</span>
|
||||
<input type="number" step="0.25" min="0.5" max="5"
|
||||
value={config.eco_z_threshold}
|
||||
onChange={e => setConfig(c => ({ ...c, eco_z_threshold: +e.target.value }))}
|
||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-400">Report importance min</span>
|
||||
<input type="number" step="1" min="1" max="5"
|
||||
value={config.report_min_importance}
|
||||
onChange={e => setConfig(c => ({ ...c, report_min_importance: +e.target.value }))}
|
||||
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-5 mb-4 flex items-center gap-2 text-red-400 text-sm bg-red-900/20 rounded-lg px-3 py-2 border border-red-800/30">
|
||||
<AlertCircle size={14} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result summary */}
|
||||
{result && (
|
||||
<div className="border-t border-slate-700/30">
|
||||
{/* Summary bar */}
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 py-3 cursor-pointer hover:bg-slate-800/30"
|
||||
onClick={() => setExpanded(x => !x)}
|
||||
>
|
||||
<CheckSquare size={14} className="text-emerald-400" />
|
||||
<span className="text-emerald-400 font-semibold text-sm">
|
||||
{result.total_created} événement{result.total_created !== 1 ? 's' : ''} créé{result.total_created !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex gap-3 text-xs text-slate-400">
|
||||
{(['news', 'eco', 'technical', 'reports'] as const).map(src => {
|
||||
const count = result.detail[src]?.length ?? 0
|
||||
return count > 0 ? (
|
||||
<span key={src}>{SOURCE_LABELS[src]}: <span className="text-slate-200">{count}</span></span>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
<span className="ml-auto text-xs text-slate-500">{result.duration_s}s</span>
|
||||
{expanded ? <ChevronUp size={14} className="text-slate-400" /> : <ChevronDown size={14} className="text-slate-400" />}
|
||||
</div>
|
||||
|
||||
{/* Event list */}
|
||||
{expanded && allEvents.length > 0 && (
|
||||
<div className="px-5 pb-4 space-y-1">
|
||||
{allEvents.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-3 py-1.5 border-b border-slate-800/60 last:border-0">
|
||||
<span className="text-xs text-slate-500 w-20 shrink-0">{ev.date}</span>
|
||||
<span className={`text-xs font-mono w-20 shrink-0 ${CAT_COLORS[ev.category] ?? 'text-slate-400'}`}>
|
||||
{ev.category?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-sm text-slate-200 flex-1">{ev.name}</span>
|
||||
<span className="text-xs text-slate-600 shrink-0">{SOURCE_LABELS[ev.source] ?? ev.source}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && allEvents.length === 0 && (
|
||||
<div className="px-5 pb-4 text-sm text-slate-500 italic">
|
||||
Aucun nouvel événement — tout était déjà enregistré ou sous le seuil.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Planned action card ───────────────────────────────────────────────────────
|
||||
|
||||
function PlannedActionCard({ action }: { action: ActionDef }) {
|
||||
return (
|
||||
<div className="bg-dark-800/40 border border-slate-800/50 rounded-xl px-5 py-4 flex items-center gap-3 opacity-50">
|
||||
<Clock size={14} className="text-slate-600 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-slate-400 text-sm font-medium">{action.label}</div>
|
||||
<div className="text-slate-600 text-xs mt-0.5 truncate">{action.description}</div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-700 border border-slate-800 rounded px-2 py-0.5 shrink-0">Planifié</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CycleActions() {
|
||||
const [actions, setActions] = useState<ActionDef[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API}/api/actions`)
|
||||
.then(r => r.json())
|
||||
.then(d => setActions(d.actions || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const planned = actions.filter(a => !a.implemented)
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-100">Actions Cycle</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">
|
||||
Décomposition du cycle en 8 actions isolées — lancez-les indépendamment pour tester ou rejouer une étape.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Implemented actions */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold">Actions disponibles</div>
|
||||
<CheckEventsPanel />
|
||||
</div>
|
||||
|
||||
{/* Planned */}
|
||||
{planned.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold">Prochaines actions</div>
|
||||
{planned.map(a => <PlannedActionCard key={a.id} action={a} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user