From f71e3be3ffdd6bb65235694d92386b2580dc0a88 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 25 Jun 2026 21:27:21 +0200 Subject: [PATCH] feat: market events filters, no-auto-bootstrap, star label deoverlap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/main.py | 13 +- backend/routers/cycle_actions.py | 88 ++++++- backend/routers/market_events.py | 166 ++++++++---- frontend/src/components/InstrumentChart.tsx | 69 +++-- frontend/src/pages/CycleActions.tsx | 129 +++++++--- frontend/src/pages/MarketEvents.tsx | 270 ++++++++++++++------ 6 files changed, 545 insertions(+), 190 deletions(-) diff --git a/backend/main.py b/backend/main.py index 196f507..76be3ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/routers/cycle_actions.py b/backend/routers/cycle_actions.py index dfc19cd..91a9471 100644 --- a/backend/routers/cycle_actions.py +++ b/backend/routers/cycle_actions.py @@ -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} diff --git a/backend/routers/market_events.py b/backend/routers/market_events.py index 2810eb8..680bbeb 100644 --- a/backend/routers/market_events.py +++ b/backend/routers/market_events.py @@ -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 ────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx index eb0f4c3..f5db3b1 100644 --- a/frontend/src/components/InstrumentChart.tsx +++ b/frontend/src/components/InstrumentChart.tsx @@ -175,56 +175,75 @@ export default function InstrumentChart({ priceData, indicators, events = [], he if (!active) return starsOverlay.innerHTML = '' const cw = containerRef.current?.clientWidth ?? 0 - const ch = containerRef.current?.clientHeight ?? 0 + // ── Collect visible events with their x coords ────────────────────── + type Placed = { xCoord: number; halfW: number; ev: typeof events[0] } + const visible: Placed[] = [] for (const ev of events) { const xCoord = chart.timeScale().timeToCoordinate(ev.date as any) if (xCoord === null || xCoord < 0 || xCoord > cw) continue + const title = ev.title.slice(0, 20) + const halfW = Math.max(30, title.length * 6 + 12) // approx px half-width + visible.push({ xCoord, halfW, ev }) + } - // Place stars at a fixed band near top of chart — never following the bars - const highPrice = candleMap[ev.date] - let yBase = 22 - if (highPrice !== undefined) { - const yHigh = candleSeries.priceToCoordinate(highPrice) - // 130px above bar high, but always capped to top 30px of chart - if (yHigh !== null) yBase = Math.min(30, Math.max(8, yHigh - 130)) + // ── Greedy row placement (up to 4 rows, 20px apart) ───────────────── + const ROW_H = 20 // pixels between rows + const MAX_ROWS = 4 + // rowEnds[r] = rightmost x already occupied in row r + const rowEnds: number[] = Array(MAX_ROWS).fill(-Infinity) + + // Sort by x so greedy works left-to-right + visible.sort((a, b) => a.xCoord - b.xCoord) + + for (const { xCoord, halfW, ev } of visible) { + const left = xCoord - halfW + const right = xCoord + halfW + 4 + + // Find first row where the label fits (no overlap) + let row = 0 + for (let r = 0; r < MAX_ROWS; r++) { + if (rowEnds[r] <= left) { row = r; break } + if (r === MAX_ROWS - 1) row = r // last resort: use bottom row } + rowEnds[row] = right + + const yBase = 6 + row * ROW_H const cat = ev.category ?? 'fundamental' const color = CAT_COLORS[cat] ?? '#94a3b8' const score = ev.impact_score ?? 0.5 - const sz = score >= 0.8 ? 18 : score >= 0.6 ? 15 : 12 + const sz = score >= 0.8 ? 16 : score >= 0.6 ? 13 : 11 const wrap = document.createElement('div') wrap.style.cssText = [ `position:absolute`, `left:${xCoord}px`, `top:${yBase}px`, `transform:translateX(-50%)`, - `display:flex`, `flex-direction:column`, `align-items:center`, `gap:2px`, + `display:flex`, `align-items:center`, `gap:3px`, `pointer-events:auto`, `cursor:default`, ].join(';') wrap.title = `${ev.title}\n${ev.date}${ev.end_date ? ' → ' + ev.end_date : ''}` - const lbl = document.createElement('div') - lbl.style.cssText = [ - `font-size:11px`, `font-family:ui-monospace,monospace`, - `color:${color}`, `white-space:nowrap`, - `text-shadow:0 1px 4px #000,0 0 8px #000,-1px 0 4px #000,1px 0 4px #000`, - `line-height:1`, `max-width:110px`, - `overflow:hidden`, `text-overflow:ellipsis`, - `font-weight:600`, - ].join(';') - lbl.textContent = ev.title.slice(0, 22) - - const star = document.createElement('div') + const star = document.createElement('span') star.style.cssText = [ `font-size:${sz}px`, `color:${color}`, `line-height:1`, - `filter:drop-shadow(0 0 4px ${color}80)`, - `text-shadow:0 1px 3px #000`, + `filter:drop-shadow(0 0 3px ${color}80)`, ].join(';') star.textContent = '★' - wrap.appendChild(lbl) + const lbl = document.createElement('span') + lbl.style.cssText = [ + `font-size:10px`, `font-family:ui-monospace,monospace`, + `color:${color}`, `white-space:nowrap`, + `text-shadow:0 1px 4px #000,0 0 6px #000`, + `line-height:1`, `max-width:100px`, + `overflow:hidden`, `text-overflow:ellipsis`, + `font-weight:600`, + ].join(';') + lbl.textContent = ev.title.slice(0, 20) + wrap.appendChild(star) + wrap.appendChild(lbl) starsOverlay.appendChild(wrap) } } diff --git a/frontend/src/pages/CycleActions.tsx b/frontend/src/pages/CycleActions.tsx index ed7a2df..1bf6135 100644 --- a/frontend/src/pages/CycleActions.tsx +++ b/frontend/src/pages/CycleActions.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Play, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronUp, Loader2 } from 'lucide-react' +import { Play, CheckSquare, Clock, AlertCircle, ChevronDown, ChevronUp, Loader2, Database } from 'lucide-react' const API = '' @@ -9,6 +9,7 @@ interface ActionDef { description: string status: 'idle' | 'running' implemented: boolean + group: string } interface CreatedEvent { @@ -49,6 +50,73 @@ const CAT_COLORS: Record = { technical: 'text-cyan-400', } +// ── Simple bootstrap action button ─────────────────────────────────────────── + +function BootstrapAction({ action }: { action: ActionDef }) { + const [running, setRunning] = useState(false) + const [result, setResult] = useState<{ inserted?: number; updated?: number; skipped?: number } | null>(null) + const [error, setError] = useState(null) + const [force, setForce] = useState(false) + + const run = async () => { + setRunning(true) + setResult(null) + setError(null) + try { + const res = await fetch(`${API}/api/actions/${action.id}?force=${force}`, { method: 'POST' }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || res.statusText) + } + const data = await res.json() + setResult(data) + } catch (e: any) { + setError(e.message) + } finally { + setRunning(false) + } + } + + return ( +
+
+ +
+
{action.label}
+
{action.description}
+
+ + +
+ {error && ( +
+ {error} +
+ )} + {result && !error && ( +
+ ✓ Terminé — + {result.inserted != null && ` ${result.inserted} insérés`} + {result.updated != null && ` · ${result.updated} mis à jour`} + {result.skipped != null && ` · ${result.skipped} ignorés`} +
+ )} +
+ ) +} + // ── Check Market Events config panel ──────────────────────────────────────── interface CheckEventsConfig { @@ -121,7 +189,6 @@ function CheckEventsPanel() { return (
- {/* Header */}
@@ -147,10 +214,8 @@ function CheckEventsPanel() {
- {/* Config panel */} {showConfig && (
- {/* Sources */}
Sources actives
@@ -169,8 +234,6 @@ function CheckEventsPanel() { ))}
- - {/* Thresholds */}
)} - {/* Error */} {error && (
- - {error} + {error}
)} - {/* Result summary */} {result && (
- {/* Summary bar */}
setExpanded(x => !x)} @@ -239,8 +298,6 @@ function CheckEventsPanel() { {result.duration_s}s {expanded ? : }
- - {/* Event list */} {expanded && allEvents.length > 0 && (
{allEvents.map((ev, i) => ( @@ -255,7 +312,6 @@ function CheckEventsPanel() { ))}
)} - {expanded && allEvents.length === 0 && (
Aucun nouvel événement — tout était déjà enregistré ou sous le seuil. @@ -284,6 +340,14 @@ function PlannedActionCard({ action }: { action: ActionDef }) { // ── Main page ───────────────────────────────────────────────────────────────── +const GROUP_LABELS: Record = { + detection: 'Détection', + bootstrap: 'Bootstrap (données initiales)', + data: 'Données & Indicateurs', + ai: 'Analyse IA', + portfolio: 'Portfolio & Rapport', +} + export default function CycleActions() { const [actions, setActions] = useState([]) @@ -294,30 +358,35 @@ export default function CycleActions() { .catch(() => {}) }, []) - const planned = actions.filter(a => !a.implemented) + const groups = ['detection', 'bootstrap', 'data', 'ai', 'portfolio'] return ( -
+

Actions Cycle

- Décomposition du cycle en 8 actions isolées — lancez-les indépendamment pour tester ou rejouer une étape. + Décomposition du cycle en actions isolées — lancez-les indépendamment pour tester ou rejouer une étape. + Les bootstraps auto au démarrage ont été désactivés.

- {/* Implemented actions */} -
-
Actions disponibles
- -
- - {/* Planned */} - {planned.length > 0 && ( -
-
Prochaines actions
- {planned.map(a => )} -
- )} + {groups.map(group => { + const groupActions = actions.filter(a => a.group === group) + if (groupActions.length === 0) return null + const hasAny = groupActions.some(a => a.implemented) + return ( +
+
+ {GROUP_LABELS[group] ?? group} +
+ {groupActions.map(action => { + if (!action.implemented) return + if (action.id === 'check-market-events') return + return + })} +
+ ) + })}
) } diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index a7bf3bd..9291cd6 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -1034,16 +1034,45 @@ export default function MarketEvents() { const [bulking, setBulking] = useState(false) const [bulkMsg, setBulkMsg] = useState(null) - // Filters - const [search, setSearch] = useState('') - const [category, setCategory] = useState('') - const [level, setLevel] = useState('') - const [minScore, setMinScore] = useState(0) - const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('') - const [dateFrom, setDateFrom] = useState('') - const [dateTo, setDateTo] = useState('') + // ── Filter state ───────────────────────────────────────────────────────── + const [search, setSearch] = useState('') + const [category, setCategory] = useState('') + const [level, setLevel] = useState('') + const [minScore, setMinScore] = useState(0) + const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + const [datePreset, setDatePreset] = useState('all') // 7d|30d|90d|6m|1y|all|custom + // Instrument impact filter + const [instTicker, setInstTicker] = useState('') + const [instMinScore, setInstMin] = useState(0.3) + const [instDir, setInstDir] = useState('') + const [showInstFilter, setShowInst] = useState(false) + // Sort + const [sortBy, setSortBy] = useState<'date'|'score'|'name'|'instrument_score'>('date') + const [sortDir, setSortDir] = useState<'desc'|'asc'>('desc') + // UI const [showFilters, setShowFilters] = useState(false) + // ── Date preset helper ──────────────────────────────────────────────────── + const applyPreset = useCallback((preset: string) => { + setDatePreset(preset) + if (preset === 'custom' || preset === 'all') { + setDateFrom(''); setDateTo('') + return + } + const now = new Date() + const to = now.toISOString().slice(0, 10) + const from = new Date(now) + if (preset === '7d') from.setDate(now.getDate() - 7) + if (preset === '30d') from.setDate(now.getDate() - 30) + if (preset === '90d') from.setDate(now.getDate() - 90) + if (preset === '6m') from.setMonth(now.getMonth() - 6) + if (preset === '1y') from.setFullYear(now.getFullYear() - 1) + setDateFrom(from.toISOString().slice(0, 10)) + setDateTo(to) + }, []) + const debounceRef = useRef | null>(null) const load = useCallback(async () => { @@ -1056,6 +1085,13 @@ export default function MarketEvents() { if (dateFrom) params.set('date_from', dateFrom) if (dateTo) params.set('date_to', dateTo) if (evaluated) params.set('evaluated', evaluated) + if (instTicker) { + params.set('instrument', instTicker) + if (instMinScore > 0) params.set('min_instrument_score', String(instMinScore)) + if (instDir) params.set('instrument_direction', instDir) + } + params.set('sort_by', instTicker ? 'instrument_score' : sortBy) + params.set('sort_dir', sortDir) params.set('limit', '500') const r = await fetch(`/api/market-events?${params}`) if (r.ok) { @@ -1064,7 +1100,8 @@ export default function MarketEvents() { setTotal(d.total) } setLoading(false) - }, [search, category, level, minScore, dateFrom, dateTo, evaluated]) + }, [search, category, level, minScore, dateFrom, dateTo, evaluated, + instTicker, instMinScore, instDir, sortBy, sortDir]) useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current) @@ -1117,55 +1154,98 @@ export default function MarketEvents() { {/* ── Events tab ── */} {activeTab === 'events' &&
{/* ── Left panel: filters + list ── */} -
- {/* Top bar */} -
+
+ {/* ── Search + actions ── */} +
-

Événements

+
+ + setSearch(e.target.value)} + placeholder="Recherche nom, description..." + className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" /> +
- {/* Search */} -
- - setSearch(e.target.value)} - placeholder="Recherche..." - className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" /> + {/* ── Date presets ── */} +
+ Période: + {[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => ( + + ))}
+ {datePreset === 'custom' && ( +
+
+
Depuis
+ setDateFrom(e.target.value)} + className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" /> +
+
+
Jusqu'à
+ setDateTo(e.target.value)} + className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" /> +
+
+ )} - {/* Quick filter chips */} -
+ {/* ── Category chips ── */} +
{CATEGORIES.map(c => ( ))} -
+
- {/* Extended filters */} - {showFilters && ( -
+ {/* ── Sort + more filters toggle ── */} +
+ Tri: + + +
+ +
+ + {/* ── Extended filters ── */} + {showFilters && ( +
+
-
Niveau
+
Niveau
-
Évaluación
+
Évaluation
-
-
Date depuis
- setDateFrom(e.target.value)} - className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" /> -
-
-
Date jusqu'à
- setDateTo(e.target.value)} - className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" /> -
-
-
Score min: {minScore.toFixed(2)}
- setMinScore(+e.target.value)} - className="w-full accent-slate-400" /> -
- )} -
+
+
Score global min: {minScore.toFixed(2)}
+ setMinScore(+e.target.value)} className="w-full accent-slate-400" /> +
- {/* Bulk actions */} + {/* ── Instrument impact filter ── */} +
+ + {showInstFilter && ( +
+
+
+
Instrument
+ +
+
+
Direction
+ +
+
+
+
+ Score impact min: {instMinScore.toFixed(2)} +
+ setInstMin(+e.target.value)} className="w-full accent-amber-400" /> +
+ {instTicker && ( +
+ ★ Tri automatique par impact sur {instTicker} +
+ )} + {instTicker && ( + + )} +
+ )} +
+
+ )} + + {/* ── Bulk evaluate ── */} {unevaluatedIds.length > 0 && ( -
- - - {unevaluatedIds.length} non évalués - +
+ {unevaluatedIds.length} non évalués {bulkMsg && {bulkMsg}} -
)} - {/* Count */} -
- {total} événement{total !== 1 ? 's' : ''} - {(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo) && ' (filtré)'} + {/* ── Count bar ── */} +
+ {loading ? '...' : total} événement{total !== 1 ? 's' : ''} + {instTicker && · impact {instTicker}} + {(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || instTicker) && ( + + )}
- {/* List */} + {/* ── List ── */}
{loading && events.length === 0 ? (