diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 3946f68..3e111ad 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -37,6 +37,7 @@ class VirtualEvent(BaseModel): class WhatIfBody(BaseModel): period: str = "1y" virtual_events: List[VirtualEvent] = [] + start_date: Optional[str] = None class CalibrateBody(BaseModel): @@ -192,10 +193,26 @@ def get_price_history( conn.close() +def _ph_period(start_date: Optional[str], fallback_period: str) -> str: + """Compute the price-history period string needed to cover start_date → today.""" + if not start_date: + return fallback_period + try: + from datetime import datetime as _dt + days = (_dt.utcnow().date() - _dt.fromisoformat(start_date[:10]).date()).days + 10 + for p, d in [("5d", 7), ("1mo", 35), ("3mo", 95), ("6mo", 190), ("1y", 370), ("2y", 740)]: + if days <= d: + return p + return "2y" + except Exception: + return fallback_period + + @router.get("/{instrument}/timeline") def get_instrument_timeline( instrument: str, - period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"), + period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"), + start_date: Optional[str] = Query(None, description="Date de début ISO (override period)"), ) -> List[Dict[str, Any]]: """Simulation jour par jour de tous les nœuds du modèle sur la période.""" from services.database import get_conn @@ -203,9 +220,8 @@ def get_instrument_timeline( from services.price_history import get_price_history conn = get_conn() try: - # Pré-peupler le cache prix pour que l'auto-anchor ait les données disponibles - get_price_history(conn, instrument.upper(), period) - data = simulate_timeline(conn, instrument.upper(), period) + get_price_history(conn, instrument.upper(), _ph_period(start_date, period)) + data = simulate_timeline(conn, instrument.upper(), period, start_date=start_date) if not data: raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") return data @@ -224,10 +240,12 @@ def timeline_whatif( from services.price_history import get_price_history conn = get_conn() try: - # Garantir que le cache prix est disponible pour l'auto-anchor - get_price_history(conn, instrument.upper(), body.period) + get_price_history(conn, instrument.upper(), _ph_period(body.start_date, body.period)) ve_list = [ve.dict() for ve in body.virtual_events] - data = simulate_timeline(conn, instrument.upper(), body.period, virtual_events=ve_list) + data = simulate_timeline( + conn, instrument.upper(), body.period, + virtual_events=ve_list, start_date=body.start_date + ) if not data: raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") return data diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index fc4bf06..ee3be49 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -1015,12 +1015,14 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt def simulate_timeline( conn, instrument: str, period: str = "1y", virtual_events: Optional[list] = None, + start_date: Optional[str] = None, ) -> list[dict]: """ Simulate all node values day by day over the period. Returns [{date, nodes: {id: value}, net_pips}]. Uses lifecycle (rise/plateau/decay) for event contributions. Manual overrides are static (applied uniformly across the period). + start_date overrides the period-based date_from when provided. """ inst_upper = instrument.upper() row = conn.execute( @@ -1033,9 +1035,15 @@ def simulate_timeline( output_id = graph_def["output_node"] period_days = {"5d":7,"1mo":35,"3mo":95,"6mo":190,"1y":370,"2y":740} - lookback = period_days.get(period, 370) - today = datetime.utcnow().date() - date_from = today - timedelta(days=lookback) + lookback = period_days.get(period, 370) + today = datetime.utcnow().date() + if start_date: + try: + date_from = date_type.fromisoformat(start_date[:10]) + except ValueError: + date_from = today - timedelta(days=lookback) + else: + date_from = today - timedelta(days=lookback) # Load overrides (static) overrides = {r["node_id"]: dict(r) for r in conn.execute( @@ -1107,6 +1115,10 @@ def simulate_timeline( for ev in events: if ev["ev_date"] > cur: continue + if ev["ev_date"] < date_from: + # Events avant la fenêtre ne portent pas de lifecycle dans la simu + # (leur impact est absorbé dans l'auto-anchor du prix de départ) + continue days = (cur - ev["ev_date"]).days df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"]) if df < 0.01: diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index eb02805..4ef2d6f 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -870,6 +870,7 @@ function newVirtualEvent(): VirtualEventForm { function TimelineView({ instrument }: { instrument: string }) { const [period, setPeriod] = useState('3mo') + const [startDate, setStartDate] = useState(null) // override period-based start const [data, setData] = useState([]) const [realPrices, setRealPrices] = useState([]) const [loading, setLoading] = useState(false) @@ -910,7 +911,8 @@ function TimelineView({ instrument }: { instrument: string }) { useEffect(() => { setLoading(true) setWhatifData(null) - api.get(`/instrument-models/${instrument}/timeline?period=${period}`) + const sdParam = startDate ? `&start_date=${startDate}` : '' + api.get(`/instrument-models/${instrument}/timeline?period=${period}${sdParam}`) .then(r => { const pts = r.data setData(pts) @@ -921,14 +923,23 @@ function TimelineView({ instrument }: { instrument: string }) { }) .catch(() => setData([])) .finally(() => setLoading(false)) - }, [instrument, period]) + }, [instrument, period, startDate]) - // Load real price history + // Load real price history — compute the right period when a custom startDate is set useEffect(() => { - api.get<{ prices: PricePoint[] }>(`/instrument-models/${instrument}/price-history?period=${period}`) + const phPeriod = startDate ? (() => { + const days = Math.ceil((Date.now() - new Date(startDate).getTime()) / 86_400_000) + 10 + if (days <= 7) return '5d' + if (days <= 35) return '1mo' + if (days <= 95) return '3mo' + if (days <= 190) return '6mo' + if (days <= 370) return '1y' + return '2y' + })() : period + api.get<{ prices: PricePoint[] }>(`/instrument-models/${instrument}/price-history?period=${phPeriod}`) .then(r => setRealPrices(r.data.prices || [])) .catch(() => setRealPrices([])) - }, [instrument, period]) + }, [instrument, period, startDate]) // Draw canvas useEffect(() => { @@ -1109,6 +1120,7 @@ function TimelineView({ instrument }: { instrument: string }) { try { const r = await api.post(`/instrument-models/${instrument}/timeline-whatif`, { period, + start_date: startDate ?? undefined, virtual_events: activeVirtuals.map(ve => ({ date: ve.date, category: ve.category, @@ -1136,12 +1148,29 @@ function TimelineView({ instrument }: { instrument: string }) { {/* Controls */}
{PERIODS.map(p => ( - ))} + {/* Custom start date */} +
+ depuis + setStartDate(e.target.value || null)} + className={clsx( + 'bg-dark-700 border rounded px-1.5 py-0.5 text-xs focus:outline-none transition-colors', + startDate ? 'border-blue-600/50 text-blue-300' : 'border-slate-700/40 text-slate-500' + )} + /> + {startDate && ( + + )} +