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:
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user