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.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"])
@@ -36,6 +36,35 @@ def update_watchlist(req: WatchlistRequest):
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}")
def snapshot_now(symbol: str):
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()
conn.close()
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 OptionsLab from './pages/OptionsLab'
import StrategyBuilder from './pages/StrategyBuilder'
import SaxoHistory from './pages/SaxoHistory'
import WaveletsSimulation from './pages/WaveletsSimulation'
import Backtest from './pages/Backtest'
import CalendarPage from './pages/CalendarPage'
@@ -52,6 +53,7 @@ const KEEP_ALIVE_DEFS: { path: string; component: ComponentType }[] = [
{ path: '/macro', component: MacroRegime },
{ path: '/options', component: OptionsLab },
{ path: '/strategy-builder', component: StrategyBuilder },
{ path: '/saxo-history', component: SaxoHistory },
{ path: '/wavelets-simulation', component: WaveletsSimulation },
{ path: '/patterns', component: PatternExplorer },
{ path: '/pattern-lab', component: PatternLab },

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
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'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -13,6 +13,7 @@ const nav = [
{ to: '/macro', icon: Activity, label: 'Macro Regime' },
{ to: '/options', icon: TrendingUp, label: 'Options Lab' },
{ 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: '/patterns', icon: Zap, label: 'Patterns' },
{ to: '/pattern-lab', icon: FlaskConical, label: 'Pattern Lab' },

View File

@@ -2,7 +2,7 @@ import {
LayoutDashboard, Globe, BarChart2, Activity, TrendingUp, Zap, FlaskConical,
DollarSign, BookOpen, FileBarChart, Brain, Microscope, ShieldAlert, Gauge,
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'
import type { LucideIcon } from 'lucide-react'
@@ -19,6 +19,7 @@ export const KEEP_ALIVE_META: KeepAliveMeta[] = [
{ path: '/macro', label: 'Macro Regime', icon: Activity },
{ path: '/options', label: 'Options Lab', icon: TrendingUp },
{ 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: '/patterns', label: 'Patterns', icon: Zap },
{ path: '/pattern-lab', label: 'Pattern Lab', icon: FlaskConical },

View File

@@ -1714,9 +1714,27 @@ export type SaxoSnapshotRow = {
created_at: string
}
export const useSaxoHistory = (symbol?: string) =>
export const useSaxoHistory = (symbol?: string, dateFrom?: string, dateTo?: string) =>
useQuery<SaxoSnapshotRow[]>({
queryKey: ['saxo-history', symbol],
queryFn: () => api.get('/saxo/history', { params: symbol ? { symbol } : {} }).then(r => r.data),
enabled: !!symbol,
queryKey: ['saxo-history', symbol, dateFrom, dateTo],
queryFn: () => api.get('/saxo/history', {
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 { Link } from 'react-router-dom'
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 { 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 { 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, ShieldCheck, ExternalLink } from 'lucide-react'
import clsx from 'clsx'
const API = ''
@@ -426,6 +427,7 @@ function SaxoConnectionCard() {
const disconnect = useDisconnectSaxo()
const updateWatchlist = useUpdateSaxoWatchlist()
const snapshotNow = useSnapshotSaxoNow()
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
const [input, setInput] = useState('')
const [snapMsg, setSnapMsg] = useState('')
@@ -508,8 +510,23 @@ function SaxoConnectionCard() {
)}
</div>
<div className="text-xs text-slate-500 mb-2">
Watchlist snapshotée périodiquement (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :
<div className="flex items-center justify-between mb-2">
<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 className="flex items-center gap-2 mb-2">
<input
@@ -525,15 +542,22 @@ function SaxoConnectionCard() {
</button>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.map(sym => (
<span key={sym} className="badge-blue flex items-center gap-1.5">
{sym}
<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>
))}
{symbols.map(sym => {
const check = validation?.find(v => v.symbol === sym.toUpperCase())
return (
<span key={sym} className={clsx('badge flex items-center gap-1.5',
check ? (check.valid ? 'badge-green' : 'badge-red') : 'badge-blue')}
title={check && !check.valid ? check.error ?? 'Symbole non résolu par Saxo' : undefined}
>
{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>
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</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>
)
}