feat: saxo history

This commit is contained in:
OpenSquared
2026-07-18 18:26:07 +02:00
parent 6cfa36b710
commit ff242bd2a6
9 changed files with 258 additions and 20 deletions

Binary file not shown.

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel
from services import saxo_auth from services import saxo_auth
from services.saxo_scheduler import get_watchlist, set_watchlist from services.saxo_scheduler import get_watchlist, set_watchlist
from services.database import get_saxo_snapshots from services.database import get_saxo_snapshots, get_saxo_snapshot_symbols
router = APIRouter(prefix="/api/saxo", tags=["saxo"]) router = APIRouter(prefix="/api/saxo", tags=["saxo"])
@@ -36,6 +36,35 @@ def update_watchlist(req: WatchlistRequest):
return {"symbols": get_watchlist()} return {"symbols": get_watchlist()}
def _validate_symbol(symbol: str) -> dict:
from services.saxo_client import resolve_option_root_uic, SaxoNotConnected
try:
uic = resolve_option_root_uic(symbol)
return {"symbol": symbol.upper(), "valid": True, "uic": uic, "error": None}
except SaxoNotConnected as e:
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
except Exception as e:
return {"symbol": symbol.upper(), "valid": False, "uic": None, "error": str(e)}
@router.get("/validate")
def validate_watchlist():
"""Checks each watchlist symbol actually resolves to a real Saxo option-root instrument."""
return [_validate_symbol(s) for s in get_watchlist()]
@router.get("/validate/{symbol}")
def validate_symbol(symbol: str):
return _validate_symbol(symbol)
@router.get("/symbols")
def symbols_with_history():
"""Distinct symbols that already have recorded snapshot history (may differ from the
current watchlist — includes ad-hoc 'snapshot now' calls and previously-tracked symbols)."""
return get_saxo_snapshot_symbols()
@router.post("/snapshot-now/{symbol}") @router.post("/snapshot-now/{symbol}")
def snapshot_now(symbol: str): def snapshot_now(symbol: str):
from services.saxo_client import snapshot_options_chain, SaxoNotConnected from services.saxo_client import snapshot_options_chain, SaxoNotConnected

View File

@@ -6116,3 +6116,13 @@ def get_saxo_snapshots(symbol: Optional[str] = None, date_from: Optional[str] =
rows = conn.execute(query, params).fetchall() rows = conn.execute(query, params).fetchall()
conn.close() conn.close()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]:
conn = get_conn()
rows = conn.execute("""
SELECT symbol, COUNT(*) AS rows_count, MIN(snapshot_date) AS first_date, MAX(snapshot_date) AS last_date
FROM saxo_option_snapshots GROUP BY symbol ORDER BY symbol
""").fetchall()
conn.close()
return [dict(r) for r in rows]

View File

@@ -10,6 +10,7 @@ import Markets from './pages/Markets'
import MacroRegime from './pages/MacroRegime' import MacroRegime from './pages/MacroRegime'
import OptionsLab from './pages/OptionsLab' import OptionsLab from './pages/OptionsLab'
import StrategyBuilder from './pages/StrategyBuilder' import StrategyBuilder from './pages/StrategyBuilder'
import SaxoHistory from './pages/SaxoHistory'
import WaveletsSimulation from './pages/WaveletsSimulation' import WaveletsSimulation from './pages/WaveletsSimulation'
import Backtest from './pages/Backtest' import Backtest from './pages/Backtest'
import CalendarPage from './pages/CalendarPage' import CalendarPage from './pages/CalendarPage'
@@ -52,6 +53,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
{ path: '/macro', component: MacroRegime }, { path: '/macro', component: MacroRegime },
{ path: '/options', component: OptionsLab }, { path: '/options', component: OptionsLab },
{ path: '/strategy-builder', component: StrategyBuilder }, { path: '/strategy-builder', component: StrategyBuilder },
{ path: '/saxo-history', component: SaxoHistory },
{ path: '/wavelets-simulation', component: WaveletsSimulation }, { path: '/wavelets-simulation', component: WaveletsSimulation },
{ path: '/patterns', component: PatternExplorer }, { path: '/patterns', component: PatternExplorer },
{ path: '/pattern-lab', component: PatternLab }, { path: '/pattern-lab', component: PatternLab },

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, Globe, BarChart2, FlaskConical, LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves, Layers History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, ScanEye, CandlestickChart, PlayCircle, Radio, Bot, Sliders, Waves, Layers, History as HistoryIcon
} from 'lucide-react' } from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx' import clsx from 'clsx'
@@ -13,6 +13,7 @@ const nav = [
{ to: '/macro', icon: Activity, label: 'Macro Regime' }, { to: '/macro', icon: Activity, label: 'Macro Regime' },
{ to: '/options', icon: TrendingUp, label: 'Options Lab' }, { to: '/options', icon: TrendingUp, label: 'Options Lab' },
{ to: '/strategy-builder', icon: Layers, label: 'Strategy Builder' }, { to: '/strategy-builder', icon: Layers, label: 'Strategy Builder' },
{ to: '/saxo-history', icon: HistoryIcon, label: 'Saxo History' },
{ to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' }, { to: '/wavelets-simulation', icon: Waves, label: 'Wavelets Simulation' },
{ to: '/patterns', icon: Zap, label: 'Patterns' }, { to: '/patterns', icon: Zap, label: 'Patterns' },
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' }, { to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },

View File

@@ -2,7 +2,7 @@ import {
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical, LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge, DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon, GitCompare, History, Calendar, Sliders, TrendingUp as MacroSeriesIcon,
Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, Layers, Building2, Users, ScanEye, Radio, Bot, PlayCircle, ScrollText, Settings, Waves, Layers, History as HistoryIcon,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
@@ -19,6 +19,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
{ path: '/macro', label: 'Macro Regime', icon: Activity }, { path: '/macro', label: 'Macro Regime', icon: Activity },
{ path: '/options', label: 'Options Lab', icon: TrendingUp }, { path: '/options', label: 'Options Lab', icon: TrendingUp },
{ path: '/strategy-builder', label: 'Strategy Builder', icon: Layers }, { path: '/strategy-builder', label: 'Strategy Builder', icon: Layers },
{ path: '/saxo-history', label: 'Saxo History', icon: HistoryIcon },
{ path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves }, { path: '/wavelets-simulation', label: 'Wavelets Sim.', icon: Waves },
{ path: '/patterns', label: 'Patterns', icon: Zap }, { path: '/patterns', label: 'Patterns', icon: Zap },
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical }, { path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },

View File

@@ -1714,9 +1714,27 @@ export type SaxoSnapshotRow = {
created_at: string created_at: string
} }
export const useSaxoHistory = (symbol?: string) => export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>
useQuery<SaxoSnapshotRow[]>({ useQuery<SaxoSnapshotRow[]>({
queryKey: ['saxo-history', symbol], queryKey: ['saxo-history', symbol, dateFrom, dateTo],
queryFn: () => api.get('/saxo/history', { params: symbol ? { symbol } : {} }).then(r => r.data), queryFn: () => api.get('/saxo/history', {
enabled: !!symbol, params: { ...(symbol ? { symbol } : {}), ...(dateFrom ? { date_from: dateFrom } : {}), ...(dateTo ? { date_to: dateTo } : {}) },
}).then(r => r.data),
})
export type SaxoSymbolSummary = { symbol: string; rows_count: number; first_date: string; last_date: string }
export const useSaxoSymbols = () =>
useQuery<SaxoSymbolSummary[]>({
queryKey: ['saxo-symbols'],
queryFn: () => api.get('/saxo/symbols').then(r => r.data),
})
export type SaxoValidation = { symbol: string; valid: boolean; uic: number | null; error: string | null }
export const useValidateSaxoWatchlist = () =>
useQuery<SaxoValidation[]>({
queryKey: ['saxo-validate'],
queryFn: () => api.get('/saxo/validate').then(r => r.data),
enabled: false,
}) })

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, type CycleStepDef } from '../hooks/useApi' import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useCycleStepCatalog, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate, useTechIndicatorsConfig, useSaveTechIndicatorsConfig, useInstrumentsWatchlist, useAddWatchlistInstrument, useRemoveWatchlistInstrument, validateTicker, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, type CycleStepDef } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera } from 'lucide-react' import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign, TrendingUp, ShieldAlert, DatabaseBackup, Radar, Link2, Unlink, Camera, ShieldCheck, ExternalLink } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
const API = '' const API = ''
@@ -426,6 +427,7 @@ function SaxoConnectionCard() {
const disconnect = useDisconnectSaxo() const disconnect = useDisconnectSaxo()
const updateWatchlist = useUpdateSaxoWatchlist() const updateWatchlist = useUpdateSaxoWatchlist()
const snapshotNow = useSnapshotSaxoNow() const snapshotNow = useSnapshotSaxoNow()
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('') const [snapMsg, setSnapMsg] = useState('')
@@ -508,8 +510,23 @@ function SaxoConnectionCard() {
)} )}
</div> </div>
<div className="text-xs text-slate-500 mb-2"> <div className="flex items-center justify-between mb-2">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) : <div className="text-xs text-slate-500">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => runValidation()}
disabled={!status?.connected || validating}
title="Vérifie que chaque symbole résout bien vers un vrai instrument Saxo"
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200 border border-slate-700/50 px-2 py-1 rounded disabled:opacity-40"
>
<ShieldCheck className={clsx('w-3.5 h-3.5', validating && 'animate-pulse')} /> Vérifier l'orthographe
</button>
<Link to="/saxo-history" className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300">
<ExternalLink className="w-3.5 h-3.5" /> Historique
</Link>
</div>
</div> </div>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<input <input
@@ -525,15 +542,22 @@ function SaxoConnectionCard() {
</button> </button>
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{symbols.map(sym => ( {symbols.map(sym => {
<span key={sym} className="badge-blue flex items-center gap-1.5"> const check = validation?.find(v => v.symbol === sym.toUpperCase())
{sym} return (
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white"> <span key={sym} className={clsx('badge flex items-center gap-1.5',
<Camera className="w-3 h-3" /> check ? (check.valid ? 'badge-green' : 'badge-red') : 'badge-blue')}
</button> title={check && !check.valid ? check.error ?? 'Symbole non résolu par Saxo' : undefined}
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button> >
</span> {sym}
))} {check && (check.valid ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />)}
<button onClick={() => handleSnapshotNow(sym)} title="Snapshot maintenant" className="hover:text-white">
<Camera className="w-3 h-3" />
</button>
<button onClick={() => removeSymbol(sym)}><X className="w-3 h-3" /></button>
</span>
)
})}
</div> </div>
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>} {snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
</div> </div>

View File

@@ -0,0 +1,153 @@
import { useMemo, useState } from 'react'
import { History, RefreshCw } from 'lucide-react'
import clsx from 'clsx'
import { useSaxoSymbols, useSaxoHistory, type SaxoSnapshotRow } from '../hooks/useApi'
function fmt(v: number | null | undefined, digits = 2) {
return v == null ? '—' : v.toFixed(digits)
}
function daysAgo(n: number) {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString().slice(0, 10)
}
export default function SaxoHistory() {
const { data: symbolSummaries = [] } = useSaxoSymbols()
const [symbol, setSymbol] = useState<string>('')
const [dateFrom, setDateFrom] = useState(daysAgo(30))
const [dateTo, setDateTo] = useState('')
const { data: rows = [], isLoading, isFetching, refetch } = useSaxoHistory(symbol || undefined, dateFrom || undefined, dateTo || undefined)
const grouped = useMemo(() => {
const groups: { date: string; rows: SaxoSnapshotRow[] }[] = []
for (const r of rows) {
const last = groups[groups.length - 1]
if (last && last.date === r.snapshot_date) last.rows.push(r)
else groups.push({ date: r.snapshot_date, rows: [r] })
}
return groups
}, [rows])
return (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-blue-400" /> Saxo Historique des snapshots
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Accumulé depuis la connexion Saxo pas d'historique passé disponible via leur API, seulement ce qu'on capture nous-mêmes.
</p>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
{isFetching ? 'Chargement...' : 'Rafraîchir'}
</button>
</div>
<div className="card flex flex-wrap items-end gap-4">
<div>
<label className="stat-label block mb-1">Symbole</label>
<select
value={symbol}
onChange={e => setSymbol(e.target.value)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white w-40"
>
<option value="">Tous</option>
{symbolSummaries.map(s => <option key={s.symbol} value={s.symbol}>{s.symbol}</option>)}
</select>
</div>
<div>
<label className="stat-label block mb-1">Du</label>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
</div>
<div>
<label className="stat-label block mb-1">Au</label>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
</div>
<div className="text-xs text-slate-500 ml-auto">{rows.length} lignes</div>
</div>
{symbolSummaries.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{symbolSummaries.map(s => (
<button
key={s.symbol}
onClick={() => setSymbol(s.symbol === symbol ? '' : s.symbol)}
className={clsx('card-sm text-left transition-colors', s.symbol === symbol && 'border-blue-500/60')}
>
<div className="text-sm font-bold text-white">{s.symbol}</div>
<div className="text-xs text-slate-500">{s.rows_count} lignes · {s.first_date} {s.last_date}</div>
</button>
))}
</div>
)}
{isLoading ? (
<div className="card-sm text-xs text-slate-500">Chargement</div>
) : rows.length === 0 ? (
<div className="card-sm text-xs text-slate-500">Aucun snapshot enregistré pour ces filtres.</div>
) : (
<div className="card overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 text-left sticky top-0 bg-dark-800">
<th className="py-1 pr-3">Symbole</th>
<th className="py-1 pr-3">Expiry</th>
<th className="py-1 pr-3 text-right">Strike</th>
<th className="py-1 pr-3">Type</th>
<th className="py-1 pr-3 text-right">Spot</th>
<th className="py-1 pr-3 text-right">Bid</th>
<th className="py-1 pr-3 text-right">Ask</th>
<th className="py-1 pr-3 text-right">Mid</th>
<th className="py-1 pr-3 text-right">IV%</th>
<th className="py-1 pr-3 text-right">Delta</th>
<th className="py-1 pr-3 text-right">Gamma</th>
<th className="py-1 pr-3 text-right">Theta</th>
<th className="py-1 pr-3 text-right">Vega</th>
</tr>
</thead>
<tbody>
{grouped.map(group => (
<>
<tr key={`h-${group.date}`} className="border-t border-slate-700/40">
<td colSpan={13} className="py-1.5 pr-3 text-slate-300 font-semibold bg-dark-700/30">{group.date}</td>
</tr>
{group.rows.map(r => (
<tr key={r.id} className="border-t border-slate-800/60 hover:bg-dark-700/30">
<td className="py-1 pr-3 text-slate-300">{r.symbol}</td>
<td className="py-1 pr-3 text-slate-400">{r.expiry_date}</td>
<td className="py-1 pr-3 text-right text-slate-300">{r.strike}</td>
<td className="py-1 pr-3">
<span className={clsx('badge', r.option_type === 'call' ? 'badge-green' : 'badge-red')}>{r.option_type}</span>
</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.spot)}</td>
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.bid)}</td>
<td className="py-1 pr-3 text-right text-slate-300">{fmt(r.ask)}</td>
<td className="py-1 pr-3 text-right text-white font-semibold">{fmt(r.mid)}</td>
{/* Saxo's field is already named "...Pct" (MidVolatilityPct) — assumed pre-scaled, not a 0-1 fraction */}
<td className="py-1 pr-3 text-right text-amber-400">{r.volatility_pct != null ? `${r.volatility_pct.toFixed(1)}%` : '—'}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.delta, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.gamma, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.theta, 4)}</td>
<td className="py-1 pr-3 text-right text-slate-400">{fmt(r.vega, 4)}</td>
</tr>
))}
</>
))}
</tbody>
</table>
</div>
)}
</div>
)
}