feat: market events filters, no-auto-bootstrap, star label deoverlap

- MarketEvents: date presets (7j/30j/3m/6m/1an/Tout/Perso), instrument
  impact filter (ticker + min score + direction → auto-sort by inst score),
  sort controls (date/score/nom + asc/desc), clear-all button, count bar
- Market events list endpoint: full SQL JOIN rewrite supporting instrument
  filter, date range, origin, sort_by=instrument_score
- Disable auto-bootstrap on startup (macro/eco/categories) — manual only
- CycleActions: bootstrap group (Macro/Eco/Categories) with force checkbox,
  grouped layout (detection / bootstrap / data / ai / portfolio)
- cycle_actions router: /bootstrap-macro, /bootstrap-eco, /bootstrap-categories
  endpoints + group field on all action catalogue entries
- InstrumentChart: greedy row placement for star labels (4 rows × 20px)
  to eliminate horizontal overlap; labels now inline (★ + text)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 21:27:21 +02:00
parent 0b1fcff49c
commit f71e3be3ff
6 changed files with 545 additions and 190 deletions

View File

@@ -81,18 +81,7 @@ def startup():
from services.geo_analyzer import GEO_PATTERNS
from services.database import seed_builtin_patterns
seed_builtin_patterns(GEO_PATTERNS)
# Bootstrap historical market events (macro/geopolitical + economic calendar)
try:
from services.macro_events_bootstrap import bootstrap_macro_events
from services.eco_calendar_bootstrap import bootstrap_eco_events
from services.impact_categories_bootstrap import bootstrap_impact_categories
r1 = bootstrap_macro_events()
r2 = bootstrap_eco_events()
r3 = bootstrap_impact_categories()
if r1.get("inserted", 0) or r2.get("inserted", 0) or r3.get("inserted", 0):
_log.info(f"[Startup] Bootstrapped — macro: +{r1.get('inserted',0)}, eco: +{r2.get('inserted',0)}, categories: +{r3.get('inserted',0)}")
except Exception as _e:
_log.warning(f"[Startup] Event bootstrap failed: {_e}")
# Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline
# Start auto-cycle scheduler if enabled
from services.auto_cycle import start_scheduler
start_scheduler()

View File

@@ -95,14 +95,40 @@ def list_actions():
"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,
"group": "detection",
},
{"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},
{
"id": "bootstrap-macro",
"label": "Bootstrap Macro Events",
"description": "Seed 30+ historical macro/geopolitical events (FOMC, NFP, elections…). Idempotent — skip duplicates.",
"status": "running" if _running.get("bootstrap-macro") else "idle",
"implemented": True,
"group": "bootstrap",
},
{
"id": "bootstrap-eco",
"label": "Bootstrap Eco Calendar",
"description": "Seed economic calendar events from FRED/ECB/BOE (last 12 months). Idempotent.",
"status": "running" if _running.get("bootstrap-eco") else "idle",
"implemented": True,
"group": "bootstrap",
},
{
"id": "bootstrap-categories",
"label": "Bootstrap Impact Categories",
"description": "Seed default event categories with instrument impact weights (FOMC, NFP, CPI, BOE…).",
"status": "running" if _running.get("bootstrap-categories") else "idle",
"implemented": True,
"group": "bootstrap",
},
{"id": "bootstrap-ma", "label": "Bootstrap MA Crossovers", "description": "Detect historical MA50/100/200 crossovers for all instruments.", "status": "idle", "implemented": False, "group": "bootstrap"},
{"id": "refresh-price-data", "label": "Refresh Price Data", "description": "Download fresh OHLCV for all watchlist instruments.", "status": "idle", "implemented": False, "group": "data"},
{"id": "compute-indicators", "label": "Compute Indicators", "description": "Recalculate MA/RSI/BB/ATR for all instruments.", "status": "idle", "implemented": False, "group": "data"},
{"id": "evaluate-impacts", "label": "Evaluate Impacts", "description": "AI-score market_events that lack impact_score.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "generate-signals", "label": "Generate Signals", "description": "Compute trading signals from indicators + events.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "update-regime", "label": "Update Regime", "description": "Re-classify the current macro regime.", "status": "idle", "implemented": False, "group": "ai"},
{"id": "snapshot-portfolio", "label": "Snapshot Portfolio", "description": "Store a risk snapshot (VaR, PnL, delta).", "status": "idle", "implemented": False, "group": "portfolio"},
{"id": "generate-report", "label": "Generate Report", "description": "Build and persist the daily cycle report.", "status": "idle", "implemented": False, "group": "portfolio"},
]
return {"actions": actions}
@@ -192,3 +218,51 @@ def check_market_events_background(
background_tasks.add_task(_run)
return {"status": "accepted", "message": "check-market-events started in background"}
# ── POST /api/actions/bootstrap-macro ────────────────────────────────────────
@router.post("/bootstrap-macro")
def run_bootstrap_macro(force: bool = Query(False)):
"""Seed macro/geopolitical historical events. Idempotent by default."""
_guard("bootstrap-macro")
try:
from services.macro_events_bootstrap import bootstrap_macro_events
result = bootstrap_macro_events(force=force)
except Exception as e:
_release("bootstrap-macro")
raise HTTPException(500, str(e))
_release("bootstrap-macro")
return {"action": "bootstrap-macro", "status": "success", **result}
# ── POST /api/actions/bootstrap-eco ──────────────────────────────────────────
@router.post("/bootstrap-eco")
def run_bootstrap_eco(force: bool = Query(False)):
"""Seed economic calendar events. Idempotent by default."""
_guard("bootstrap-eco")
try:
from services.eco_calendar_bootstrap import bootstrap_eco_events
result = bootstrap_eco_events(force=force)
except Exception as e:
_release("bootstrap-eco")
raise HTTPException(500, str(e))
_release("bootstrap-eco")
return {"action": "bootstrap-eco", "status": "success", **result}
# ── POST /api/actions/bootstrap-categories ────────────────────────────────────
@router.post("/bootstrap-categories")
def run_bootstrap_categories(force: bool = Query(False)):
"""Seed default event categories with impact weights. Idempotent."""
_guard("bootstrap-categories")
try:
from services.impact_categories_bootstrap import bootstrap_impact_categories
result = bootstrap_impact_categories(force=force)
except Exception as e:
_release("bootstrap-categories")
raise HTTPException(500, str(e))
_release("bootstrap-categories")
return {"action": "bootstrap-categories", "status": "success", **result}

View File

@@ -77,59 +77,137 @@ def _parse_event(ev: Dict) -> Dict:
# ── List / filter ─────────────────────────────────────────────────────────────
_SORT_COLS = {
"date": "me.start_date",
"score": "me.impact_score",
"name": "me.name",
"instrument_score": "COALESCE(ii.adjusted_score, ii.impact_score)",
}
@router.get("")
def list_events(
category: Optional[str] = Query(None),
level: Optional[str] = Query(None),
search: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
min_score: float = Query(0.0, ge=0.0, le=1.0),
evaluated: Optional[bool] = Query(None, description="True=only evaluated, False=only not evaluated"),
# text / category / level
search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
level: Optional[str] = Query(None),
origin: Optional[str] = Query(None),
# date range
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
# global impact score
min_score: float = Query(0.0, ge=0.0, le=1.0),
# instrument impact filter
instrument: Optional[str] = Query(None, description="Filter by impact on this ticker"),
min_instrument_score: float = Query(0.0, ge=0.0, le=1.0),
instrument_direction: Optional[str] = Query(None, description="bullish|bearish|neutral"),
# evaluation state
evaluated: Optional[bool] = Query(None),
# sort
sort_by: str = Query("date", description="date|score|name|instrument_score"),
sort_dir: str = Query("desc", description="asc|desc"),
# pagination
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
offset: int = Query(0, ge=0),
) -> Dict[str, Any]:
from services.database import get_all_market_events, get_conn
from services.database import get_conn
events = get_all_market_events()
conn = get_conn()
# Collect evaluated event IDs
evaluated_ids: set = set()
if evaluated is not None:
conn = get_conn()
rows = conn.execute(
"SELECT DISTINCT source_id FROM instrument_impacts WHERE source_type='event'"
).fetchall()
# Decide join type: INNER when filtering by instrument, LEFT otherwise
join_type = "INNER" if instrument else "LEFT"
join_clause = (
f"{join_type} JOIN instrument_impacts ii "
"ON ii.source_type='event' AND ii.source_id=me.id"
+ (f" AND ii.instrument_id='{instrument}'" if instrument else "")
)
where_parts: List[str] = []
params: List[Any] = []
if search:
where_parts.append("(me.name LIKE ? OR me.description LIKE ? OR me.sub_type LIKE ?)")
like = f"%{search}%"
params += [like, like, like]
if category:
where_parts.append("me.category=?")
params.append(category)
if level:
where_parts.append("me.level=?")
params.append(level)
if origin:
where_parts.append("me.origin=?")
params.append(origin)
if date_from:
where_parts.append("me.start_date>=?")
params.append(date_from)
if date_to:
where_parts.append("me.start_date<=?")
params.append(date_to)
if min_score > 0:
where_parts.append("me.impact_score>=?")
params.append(min_score)
if instrument and min_instrument_score > 0:
where_parts.append("COALESCE(ii.adjusted_score, ii.impact_score)>=?")
params.append(min_instrument_score)
if instrument and instrument_direction:
where_parts.append("ii.direction=?")
params.append(instrument_direction)
if evaluated is True:
where_parts.append(
"EXISTS (SELECT 1 FROM instrument_impacts x WHERE x.source_type='event' AND x.source_id=me.id)"
)
if evaluated is False:
where_parts.append(
"NOT EXISTS (SELECT 1 FROM instrument_impacts x WHERE x.source_type='event' AND x.source_id=me.id)"
)
where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
sort_col = _SORT_COLS.get(sort_by, "me.start_date")
if sort_by == "instrument_score" and not instrument:
sort_col = "me.impact_score"
order_sql = f"ORDER BY {sort_col} {'DESC' if sort_dir == 'desc' else 'ASC'}"
# When joining with instrument_impacts an event can appear multiple times — use GROUP BY
group_sql = "GROUP BY me.id" if instrument else ""
# Extra column: best instrument score for display
inst_col = (
", MAX(COALESCE(ii.adjusted_score, ii.impact_score)) as inst_score"
", ii.direction as inst_direction"
) if instrument else ", NULL as inst_score, NULL as inst_direction"
count_sql = f"""
SELECT COUNT(DISTINCT me.id) FROM market_events me {join_clause} {where_sql}
"""
data_sql = f"""
SELECT me.* {inst_col}
FROM market_events me {join_clause}
{where_sql} {group_sql} {order_sql}
LIMIT ? OFFSET ?
"""
try:
total = conn.execute(count_sql, params).fetchone()[0]
rows = conn.execute(data_sql, params + [limit, offset]).fetchall()
finally:
conn.close()
evaluated_ids = {r[0] for r in rows}
filtered = []
for ev in events:
if category and ev.get("category") != category:
continue
if level and ev.get("level") != level:
continue
if date_from and ev.get("start_date", "") < date_from:
continue
if date_to and ev.get("start_date", "") > date_to:
continue
if (ev.get("impact_score") or 0) < min_score:
continue
if search:
s = search.lower()
if s not in (ev.get("name") or "").lower() and \
s not in (ev.get("description") or "").lower() and \
s not in (ev.get("sub_type") or "").lower():
continue
if evaluated is True and ev["id"] not in evaluated_ids:
continue
if evaluated is False and ev["id"] in evaluated_ids:
continue
filtered.append(_parse_event(ev))
events = []
for row in rows:
ev = _parse_event(dict(row))
if instrument and ev.get("inst_score") is not None:
ev["instrument_filter"] = {
"ticker": instrument,
"score": round(float(ev.pop("inst_score")), 3),
"direction": ev.pop("inst_direction", None),
}
else:
ev.pop("inst_score", None)
ev.pop("inst_direction", None)
events.append(ev)
total = len(filtered)
page = filtered[offset: offset + limit]
return {"total": total, "offset": offset, "limit": limit, "events": page}
return {"total": total, "offset": offset, "limit": limit, "events": events}
# ── CRUD ──────────────────────────────────────────────────────────────────────