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 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 été 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user