feat: instrument model
This commit is contained in:
@@ -37,6 +37,7 @@ class VirtualEvent(BaseModel):
|
|||||||
class WhatIfBody(BaseModel):
|
class WhatIfBody(BaseModel):
|
||||||
period: str = "1y"
|
period: str = "1y"
|
||||||
virtual_events: List[VirtualEvent] = []
|
virtual_events: List[VirtualEvent] = []
|
||||||
|
start_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CalibrateBody(BaseModel):
|
class CalibrateBody(BaseModel):
|
||||||
@@ -192,10 +193,26 @@ def get_price_history(
|
|||||||
conn.close()
|
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")
|
@router.get("/{instrument}/timeline")
|
||||||
def get_instrument_timeline(
|
def get_instrument_timeline(
|
||||||
instrument: str,
|
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]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Simulation jour par jour de tous les nœuds du modèle sur la période."""
|
"""Simulation jour par jour de tous les nœuds du modèle sur la période."""
|
||||||
from services.database import get_conn
|
from services.database import get_conn
|
||||||
@@ -203,9 +220,8 @@ def get_instrument_timeline(
|
|||||||
from services.price_history import get_price_history
|
from services.price_history import get_price_history
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
# Pré-peupler le cache prix pour que l'auto-anchor ait les données disponibles
|
get_price_history(conn, instrument.upper(), _ph_period(start_date, period))
|
||||||
get_price_history(conn, instrument.upper(), period)
|
data = simulate_timeline(conn, instrument.upper(), period, start_date=start_date)
|
||||||
data = simulate_timeline(conn, instrument.upper(), period)
|
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
|
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
|
||||||
return data
|
return data
|
||||||
@@ -224,10 +240,12 @@ def timeline_whatif(
|
|||||||
from services.price_history import get_price_history
|
from services.price_history import get_price_history
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
try:
|
try:
|
||||||
# Garantir que le cache prix est disponible pour l'auto-anchor
|
get_price_history(conn, instrument.upper(), _ph_period(body.start_date, body.period))
|
||||||
get_price_history(conn, instrument.upper(), body.period)
|
|
||||||
ve_list = [ve.dict() for ve in body.virtual_events]
|
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:
|
if not data:
|
||||||
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
|
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1015,12 +1015,14 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
|
|||||||
def simulate_timeline(
|
def simulate_timeline(
|
||||||
conn, instrument: str, period: str = "1y",
|
conn, instrument: str, period: str = "1y",
|
||||||
virtual_events: Optional[list] = None,
|
virtual_events: Optional[list] = None,
|
||||||
|
start_date: Optional[str] = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Simulate all node values day by day over the period.
|
Simulate all node values day by day over the period.
|
||||||
Returns [{date, nodes: {id: value}, net_pips}].
|
Returns [{date, nodes: {id: value}, net_pips}].
|
||||||
Uses lifecycle (rise/plateau/decay) for event contributions.
|
Uses lifecycle (rise/plateau/decay) for event contributions.
|
||||||
Manual overrides are static (applied uniformly across the period).
|
Manual overrides are static (applied uniformly across the period).
|
||||||
|
start_date overrides the period-based date_from when provided.
|
||||||
"""
|
"""
|
||||||
inst_upper = instrument.upper()
|
inst_upper = instrument.upper()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -1033,9 +1035,15 @@ def simulate_timeline(
|
|||||||
output_id = graph_def["output_node"]
|
output_id = graph_def["output_node"]
|
||||||
|
|
||||||
period_days = {"5d":7,"1mo":35,"3mo":95,"6mo":190,"1y":370,"2y":740}
|
period_days = {"5d":7,"1mo":35,"3mo":95,"6mo":190,"1y":370,"2y":740}
|
||||||
lookback = period_days.get(period, 370)
|
lookback = period_days.get(period, 370)
|
||||||
today = datetime.utcnow().date()
|
today = datetime.utcnow().date()
|
||||||
date_from = today - timedelta(days=lookback)
|
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)
|
# Load overrides (static)
|
||||||
overrides = {r["node_id"]: dict(r) for r in conn.execute(
|
overrides = {r["node_id"]: dict(r) for r in conn.execute(
|
||||||
@@ -1107,6 +1115,10 @@ def simulate_timeline(
|
|||||||
for ev in events:
|
for ev in events:
|
||||||
if ev["ev_date"] > cur:
|
if ev["ev_date"] > cur:
|
||||||
continue
|
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
|
days = (cur - ev["ev_date"]).days
|
||||||
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
||||||
if df < 0.01:
|
if df < 0.01:
|
||||||
|
|||||||
@@ -870,6 +870,7 @@ function newVirtualEvent(): VirtualEventForm {
|
|||||||
|
|
||||||
function TimelineView({ instrument }: { instrument: string }) {
|
function TimelineView({ instrument }: { instrument: string }) {
|
||||||
const [period, setPeriod] = useState<Period>('3mo')
|
const [period, setPeriod] = useState<Period>('3mo')
|
||||||
|
const [startDate, setStartDate] = useState<string | null>(null) // override period-based start
|
||||||
const [data, setData] = useState<TimelinePoint[]>([])
|
const [data, setData] = useState<TimelinePoint[]>([])
|
||||||
const [realPrices, setRealPrices] = useState<PricePoint[]>([])
|
const [realPrices, setRealPrices] = useState<PricePoint[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -910,7 +911,8 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setWhatifData(null)
|
setWhatifData(null)
|
||||||
api.get<TimelinePoint[]>(`/instrument-models/${instrument}/timeline?period=${period}`)
|
const sdParam = startDate ? `&start_date=${startDate}` : ''
|
||||||
|
api.get<TimelinePoint[]>(`/instrument-models/${instrument}/timeline?period=${period}${sdParam}`)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
const pts = r.data
|
const pts = r.data
|
||||||
setData(pts)
|
setData(pts)
|
||||||
@@ -921,14 +923,23 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
})
|
})
|
||||||
.catch(() => setData([]))
|
.catch(() => setData([]))
|
||||||
.finally(() => setLoading(false))
|
.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(() => {
|
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 || []))
|
.then(r => setRealPrices(r.data.prices || []))
|
||||||
.catch(() => setRealPrices([]))
|
.catch(() => setRealPrices([]))
|
||||||
}, [instrument, period])
|
}, [instrument, period, startDate])
|
||||||
|
|
||||||
// Draw canvas
|
// Draw canvas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1109,6 +1120,7 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
try {
|
try {
|
||||||
const r = await api.post<TimelinePoint[]>(`/instrument-models/${instrument}/timeline-whatif`, {
|
const r = await api.post<TimelinePoint[]>(`/instrument-models/${instrument}/timeline-whatif`, {
|
||||||
period,
|
period,
|
||||||
|
start_date: startDate ?? undefined,
|
||||||
virtual_events: activeVirtuals.map(ve => ({
|
virtual_events: activeVirtuals.map(ve => ({
|
||||||
date: ve.date,
|
date: ve.date,
|
||||||
category: ve.category,
|
category: ve.category,
|
||||||
@@ -1136,12 +1148,29 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{PERIODS.map(p => (
|
{PERIODS.map(p => (
|
||||||
<button key={p} onClick={() => setPeriod(p)}
|
<button key={p} onClick={() => { setPeriod(p); setStartDate(null) }}
|
||||||
className={clsx('px-2 py-1 rounded text-xs font-medium transition-colors',
|
className={clsx('px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||||
period === p ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
period === p && !startDate ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
|
||||||
{p}
|
{p}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{/* Custom start date */}
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<span className="text-xs text-slate-500">depuis</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate ?? ''}
|
||||||
|
max={new Date().toISOString().slice(0, 10)}
|
||||||
|
onChange={e => 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 && (
|
||||||
|
<button onClick={() => setStartDate(null)} className="text-slate-500 hover:text-slate-300 text-xs">✕</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="ml-2 flex gap-1.5">
|
<div className="ml-2 flex gap-1.5">
|
||||||
<button onClick={() => setShowPips(v => !v)}
|
<button onClick={() => setShowPips(v => !v)}
|
||||||
className={clsx('px-2 py-1 rounded text-xs border transition-all',
|
className={clsx('px-2 py-1 rounded text-xs border transition-all',
|
||||||
@@ -1227,8 +1256,11 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setImportingCal(true)
|
setImportingCal(true)
|
||||||
try {
|
try {
|
||||||
|
const daysBack = startDate
|
||||||
|
? Math.ceil((Date.now() - new Date(startDate).getTime()) / 86_400_000) + 5
|
||||||
|
: 365
|
||||||
const r = await api.get<CalendarEvent[]>(
|
const r = await api.get<CalendarEvent[]>(
|
||||||
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
|
`/instrument-models/${instrument}/calendar-events?days_back=${daysBack}&days_forward=90`
|
||||||
)
|
)
|
||||||
const imported: VirtualEventForm[] = r.data.map((ev, i) => ({
|
const imported: VirtualEventForm[] = r.data.map((ev, i) => ({
|
||||||
id: `ff_${ev.date}_${ev.currency}_${i}`,
|
id: `ff_${ev.date}_${ev.currency}_${i}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user