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 ──────────────────────────────────────────────────────────────────────

View File

@@ -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)
}
}

View File

@@ -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<string, string> = {
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<string | null>(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 (
<div className="bg-dark-800/60 border border-slate-700/40 rounded-xl px-5 py-4">
<div className="flex items-center gap-3">
<Database size={14} className="text-blue-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-slate-100 text-sm font-medium">{action.label}</div>
<div className="text-slate-500 text-xs mt-0.5">{action.description}</div>
</div>
<label className="flex items-center gap-1.5 text-xs text-slate-500 cursor-pointer shrink-0">
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)}
className="w-3 h-3 accent-blue-500" />
Force
</label>
<button
disabled={running}
onClick={run}
className="flex items-center gap-2 bg-blue-700 hover:bg-blue-600 disabled:bg-slate-700
disabled:text-slate-500 text-white text-xs font-medium rounded-lg px-3 py-2 transition-colors shrink-0"
>
{running ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
{running ? 'En cours...' : 'Lancer'}
</button>
</div>
{error && (
<div className="mt-2 flex items-center gap-2 text-red-400 text-xs bg-red-900/20 rounded px-3 py-1.5 border border-red-800/30">
<AlertCircle size={12} /> {error}
</div>
)}
{result && !error && (
<div className="mt-2 text-xs text-emerald-300 bg-emerald-900/20 rounded px-3 py-1.5 border border-emerald-800/30">
Terminé
{result.inserted != null && ` ${result.inserted} insérés`}
{result.updated != null && ` · ${result.updated} mis à jour`}
{result.skipped != null && ` · ${result.skipped} ignorés`}
</div>
)}
</div>
)
}
// ── Check Market Events config panel ────────────────────────────────────────
interface CheckEventsConfig {
@@ -121,7 +189,6 @@ function CheckEventsPanel() {
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" />
@@ -147,10 +214,8 @@ function CheckEventsPanel() {
</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">
@@ -169,8 +234,6 @@ function CheckEventsPanel() {
))}
</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>
@@ -208,18 +271,14 @@ function CheckEventsPanel() {
</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}
<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)}
@@ -239,8 +298,6 @@ function CheckEventsPanel() {
<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) => (
@@ -255,7 +312,6 @@ function CheckEventsPanel() {
))}
</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.
@@ -284,6 +340,14 @@ function PlannedActionCard({ action }: { action: ActionDef }) {
// ── Main page ─────────────────────────────────────────────────────────────────
const GROUP_LABELS: Record<string, string> = {
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<ActionDef[]>([])
@@ -294,30 +358,35 @@ export default function CycleActions() {
.catch(() => {})
}, [])
const planned = actions.filter(a => !a.implemented)
const groups = ['detection', 'bootstrap', 'data', 'ai', 'portfolio']
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-8 max-w-4xl">
<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.
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 é désactivés.
</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>
)}
{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 (
<div key={group} className="space-y-3">
<div className="text-xs uppercase tracking-widest text-slate-500 font-semibold border-b border-slate-800/60 pb-1.5">
{GROUP_LABELS[group] ?? group}
</div>
{groupActions.map(action => {
if (!action.implemented) return <PlannedActionCard key={action.id} action={action} />
if (action.id === 'check-market-events') return <CheckEventsPanel key={action.id} />
return <BootstrapAction key={action.id} action={action} />
})}
</div>
)
})}
</div>
)
}

View File

@@ -1034,16 +1034,45 @@ export default function MarketEvents() {
const [bulking, setBulking] = useState(false)
const [bulkMsg, setBulkMsg] = useState<string | null>(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<string>('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<ReturnType<typeof setTimeout> | 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' && <div className="flex flex-1 overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* Top bar */}
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
<div className="w-96 shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* ── Search + actions ── */}
<div className="px-3 pt-3 pb-2 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-base font-bold text-slate-100 flex-1">Événements</h1>
<div className="relative flex-1">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => 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" />
</div>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2.5 py-1.5">
<Plus size={12} /> Nouveau
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2 py-1.5 shrink-0">
<Plus size={12} />
</button>
<button onClick={load} disabled={loading}
className="p-1.5 text-slate-500 hover:text-slate-300 hover:bg-slate-700/50 rounded">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
className="p-1.5 text-slate-500 hover:text-slate-300 rounded shrink-0">
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => 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 ── */}
<div className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-slate-600 mr-1">Période:</span>
{[['7d','7j'],['30d','30j'],['90d','3m'],['6m','6m'],['1y','1an'],['all','Tout'],['custom','Perso']] .map(([v, lbl]) => (
<button key={v} onClick={() => applyPreset(v)}
className={clsx('px-2 py-0.5 rounded text-xs border transition-colors',
datePreset === v
? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{lbl}
</button>
))}
</div>
{datePreset === 'custom' && (
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
</div>
)}
{/* Quick filter chips */}
<div className="flex flex-wrap gap-1.5">
{/* ── Category chips ── */}
<div className="flex flex-wrap gap-1">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
className={clsx(
'px-2 py-0.5 rounded-full text-xs border transition-colors',
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors',
category === c
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400',
)}>
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400')}>
{c.replace('_', ' ')}
</button>
))}
<button onClick={() => setShowFilters(x => !x)}
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors ml-auto',
showFilters ? 'bg-slate-700 text-slate-300 border-slate-600' : 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={11} className="inline mr-0.5" />
Filtres
</button>
</div>
</div>
{/* Extended filters */}
{showFilters && (
<div className="grid grid-cols-2 gap-2 pt-1">
{/* ── Sort + more filters toggle ── */}
<div className="px-3 py-1.5 border-b border-slate-800/40 flex items-center gap-2">
<span className="text-xs text-slate-600">Tri:</span>
<select value={sortBy} onChange={e => setSortBy(e.target.value as any)}
className="bg-transparent border-0 text-xs text-slate-400 cursor-pointer focus:outline-none">
<option value="date">Date</option>
<option value="score">Score global</option>
<option value="name">Nom</option>
</select>
<button onClick={() => setSortDir(d => d === 'desc' ? 'asc' : 'desc')}
className="text-xs text-slate-500 hover:text-slate-300 border border-slate-700 rounded px-1.5 py-0.5">
{sortDir === 'desc' ? '' : ''}
</button>
<div className="flex-1" />
<button onClick={() => setShowFilters(x => !x)}
className={clsx('flex items-center gap-1 text-xs border rounded px-2 py-0.5 transition-colors',
(showFilters || instTicker)
? 'bg-slate-700 border-slate-600 text-slate-300'
: 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={10} />
Filtres {(level || minScore > 0 || evaluated || instTicker) ? '' : ''}
</button>
</div>
{/* ── Extended filters ── */}
{showFilters && (
<div className="px-3 py-2 border-b border-slate-800/40 space-y-2 bg-dark-900/50">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-1">Niveau</div>
<div className="text-xs text-slate-600 mb-0.5">Niveau</div>
<select value={level} onChange={e => setLevel(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
@@ -1173,44 +1253,83 @@ export default function MarketEvents() {
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Évaluación</div>
<div className="text-xs text-slate-600 mb-0.5">Évaluation</div>
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
<option value="true">Évalués</option>
<option value="true">Évalués IA</option>
<option value="false">Non évalués</option>
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div className="col-span-2">
<div className="text-xs text-slate-600 mb-1">Score min: {minScore.toFixed(2)}</div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)}
className="w-full accent-slate-400" />
</div>
</div>
)}
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Score global min: <span className="text-slate-400">{minScore.toFixed(2)}</span></div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)} className="w-full accent-slate-400" />
</div>
{/* Bulk actions */}
{/* ── Instrument impact filter ── */}
<div className="border-t border-slate-800/60 pt-2">
<button onClick={() => setShowInst(x => !x)}
className={clsx('flex items-center gap-1.5 text-xs font-semibold mb-2 transition-colors',
instTicker ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
<BarChart2 size={11} />
Impact sur instrument {instTicker ? `(${instTicker})` : ''}
{showInstFilter ? ' ' : ' '}
</button>
{showInstFilter && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs text-slate-600 mb-0.5">Instrument</div>
<select value={instTicker} onChange={e => { setInstTicker(e.target.value); if (e.target.value) setSortBy('instrument_score') }}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">Direction</div>
<select value={instDir} onChange={e => setInstDir(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Toutes</option>
<option value="bullish">🔼 Bullish</option>
<option value="bearish">🔽 Bearish</option>
<option value="neutral">● Neutre</option>
<option value="depends_on_outcome">◆ Conditionnel</option>
</select>
</div>
</div>
<div>
<div className="text-xs text-slate-600 mb-0.5">
Score impact min: <span className="text-amber-300">{instMinScore.toFixed(2)}</span>
</div>
<input type="range" min="0" max="1" step="0.05" value={instMinScore}
onChange={e => setInstMin(+e.target.value)} className="w-full accent-amber-400" />
</div>
{instTicker && (
<div className="text-xs text-amber-300 bg-amber-900/20 rounded px-2 py-1 border border-amber-800/30">
★ Tri automatique par impact sur {instTicker}
</div>
)}
{instTicker && (
<button onClick={() => { setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date') }}
className="text-xs text-slate-500 hover:text-red-400">
× Effacer filtre instrument
</button>
)}
</div>
)}
</div>
</div>
)}
{/* ── Bulk evaluate ── */}
{unevaluatedIds.length > 0 && (
<div className="px-4 py-2 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<Users size={12} className="text-blue-400" />
<span className="text-xs text-blue-300 flex-1">
{unevaluatedIds.length} non évalués
</span>
<div className="px-3 py-1.5 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<span className="text-xs text-blue-300 flex-1">{unevaluatedIds.length} non évalués</span>
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
<button
onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
<button onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
disabled={bulking}
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
@@ -1219,13 +1338,20 @@ export default function MarketEvents() {
</div>
)}
{/* Count */}
<div className="px-4 py-1.5 text-xs text-slate-600 border-b border-slate-800/30">
{total} événement{total !== 1 ? 's' : ''}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo) && ' (filtré)'}
{/* ── Count bar ── */}
<div className="px-3 py-1 text-xs text-slate-600 border-b border-slate-800/30 flex items-center gap-2">
<span>{loading ? '...' : total} événement{total !== 1 ? 's' : ''}</span>
{instTicker && <span className="text-amber-400">· impact {instTicker}</span>}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo || instTicker) && (
<button onClick={() => {
setSearch(''); setCategory(''); setLevel(''); setMinScore(0)
setEvaluated(''); setDateFrom(''); setDateTo(''); setDatePreset('all')
setInstTicker(''); setInstDir(''); setInstMin(0.3); setSortBy('date')
}} className="ml-auto text-xs text-slate-600 hover:text-red-400">× tout effacer</button>
)}
</div>
{/* List */}
{/* ── List ── */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">