From b8e2a5169c2a5b68a821707c4a88546acd974bc3 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 30 Jul 2026 17:43:49 +0200 Subject: [PATCH] feat: saxo price --- backend/routers/instruments.py | 23 +++++++ backend/services/instrument_service.py | 28 ++++++++ frontend/src/hooks/useApi.ts | 9 +++ frontend/src/pages/Config.tsx | 95 ++++++++++++++++++++++++-- frontend/src/pages/StrategyBuilder.tsx | 6 +- 5 files changed, 151 insertions(+), 10 deletions(-) diff --git a/backend/routers/instruments.py b/backend/routers/instruments.py index 3f20ad7..7947da4 100644 --- a/backend/routers/instruments.py +++ b/backend/routers/instruments.py @@ -17,6 +17,7 @@ from services.instrument_service import ( update_instrument_drivers, update_instrument_saxo_link, quick_add_instrument_from_watchlist, + add_manual_instrument, ) class DriverUpdate(BaseModel): @@ -30,6 +31,14 @@ class SaxoLinkBody(BaseModel): class QuickAddBody(BaseModel): ticker: str + +class AddManualBody(BaseModel): + id: str + name: str + category: str + yf_ticker: Optional[str] = None + saxo_quote_symbol: Optional[str] = None + router = APIRouter(prefix="/api/instruments", tags=["instruments"]) @@ -51,6 +60,20 @@ def quick_add(body: QuickAddBody) -> Dict[str, Any]: raise HTTPException(status_code=404, detail=str(e)) +@router.post("/add-manual") +def add_manual(body: AddManualBody) -> Dict[str, Any]: + """Create a standalone Instrument Analysis entry from scratch — Config page's "Ajouter + un ticker" zone, for instruments that aren't in the Cockpit Watchlist (so quick-add + doesn't apply) and don't warrant hand-authoring an instruments.json entry.""" + try: + return add_manual_instrument( + body.id, name=body.name, category=body.category, + yf_ticker=body.yf_ticker, saxo_quote_symbol=body.saxo_quote_symbol, + ) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + @router.get("/{instrument_id}/snapshot") async def instrument_snapshot( instrument_id: str, diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index 2130b24..5e05a22 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -227,6 +227,34 @@ def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]: return {"id": uid, "created": True} +def add_manual_instrument( + instrument_id: str, name: str, category: str, + yf_ticker: Optional[str] = None, saxo_quote_symbol: Optional[str] = None, +) -> Dict[str, Any]: + """Create a standalone Instrument Analysis entry directly (Config page's "Ajouter un + ticker" zone) — same instrument_overrides row shape as quick_add_instrument_from_watchlist + above, but without requiring a pre-existing Cockpit Watchlist row, since the whole point + here is to add a ticker that isn't tracked anywhere yet (e.g. EURCHF, resolved live + against Saxo just before this call). Rejects an id that already resolves — use the + existing drivers/saxo-link editors to modify it instead of silently overwriting.""" + global _configs + if _configs is None: + _load_configs() + + uid = instrument_id.strip().upper() + if uid in _configs: + raise ValueError(f"'{uid}' existe déjà dans Instrument Analysis") + + from services.database import set_instrument_override_quick_add + set_instrument_override_quick_add( + uid, name=name.strip() or uid, yf_ticker=(yf_ticker or uid).strip().upper(), + category=category, saxo_quote_symbol=saxo_quote_symbol, + ) + _load_configs() + logger.info(f"[instrument_service] Manually added {uid} (category={category}, saxo={saxo_quote_symbol})") + return {"id": uid, "created": True} + + # ── DataFrame helpers ────────────────────────────────────────────────────────── def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame: diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 30d837c..df6e2f7 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -211,6 +211,15 @@ export const useQuickAddInstrument = () => api.post('/instruments/quick-add', { ticker }).then(r => r.data as { id: string; created: boolean }), }) +// Config page's "Ajouter un ticker" zone — creates a standalone Instrument Analysis entry +// (instrument_overrides row) for a ticker that isn't in the Cockpit Watchlist, so +// useQuickAddInstrument above doesn't apply. Same "no instruments.json edit needed" model. +export const useAddManualInstrument = () => + useMutation({ + mutationFn: (body: { id: string; name: string; category: string; yf_ticker?: string; saxo_quote_symbol?: string | null }) => + api.post('/instruments/add-manual', body).then(r => r.data as { id: string; created: boolean }), + }) + // Free-form display name — no longer tied to yfinance's long_name lookup, since an // instrument can now be entirely Saxo-sourced. export const useRenameWatchlistInstrument = () => { diff --git a/frontend/src/pages/Config.tsx b/frontend/src/pages/Config.tsx index 377136b..c8bbd3a 100644 --- a/frontend/src/pages/Config.tsx +++ b/frontend/src/pages/Config.tsx @@ -1,7 +1,7 @@ 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, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, useWaveletRefreshSettings, useUpdateWaveletRefreshSettings, useWaveletRefreshNow, 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, useSetWatchlistSaxoOptionLink, useSetWatchlistSaxoQuoteLink, useRenameWatchlistInstrument, useSaxoStatus, useDisconnectSaxo, useSaxoWatchlist, useUpdateSaxoWatchlist, useSnapshotSaxoNow, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, useWaveletRefreshSettings, useUpdateWaveletRefreshSettings, useWaveletRefreshNow, useAddManualInstrument, 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' import SaxoLinkPicker from '../components/SaxoLinkPicker' @@ -33,6 +33,21 @@ const SOURCE_DOCS: Record { + if (!testQuote.data) return + setAddName(testQuote.data.description ?? testQuote.data.symbol) + setAddYfTicker(quoteAssetType === 'FxSpot' ? `${testQuote.data.symbol}=X` : testQuote.data.symbol) + setAddCategory(quoteAssetType === 'FxSpot' ? 'fx' : quoteAssetType === 'StockIndex' ? 'equity_index' : 'stock') + setAddMsg('') + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [testQuote.data]) + + const handleAddManual = async () => { + if (!testQuote.data) return + setAddMsg('') + try { + const r = await addManual.mutateAsync({ + id: testQuote.data.symbol, + name: addName.trim() || testQuote.data.description || testQuote.data.symbol, + category: addCategory, + yf_ticker: addYfTicker.trim() || undefined, + saxo_quote_symbol: testQuote.data.symbol, + }) + setAddMsg(`✓ ${r.id} ajouté à Instrument Analysis`) + } catch (e: any) { + setAddMsg(`✗ ${e?.response?.data?.detail ?? 'échec'}`) + } + } + const handleSnapshotAllNow = async () => { setSnapMsg('') try { @@ -772,13 +820,13 @@ function SaxoConnectionCard() {
- Tester un prix hors options (isole si un blocage est spécifique aux options ou plus large) : + Ajouter un ticker à Instrument Analysis (teste la résolution Saxo, hors options — ex. FX spot comme EURCHF) :
setQuoteSymbol(e.target.value.toUpperCase())} - placeholder="EURUSD" + placeholder="EURCHF" className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-32 focus:outline-none focus:border-blue-500/50" /> setAddName(e.target.value)} + placeholder="Nom affiché" + className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-40 focus:outline-none focus:border-blue-500/50" + /> + + setAddYfTicker(e.target.value.toUpperCase())} + placeholder="Ticker yfinance (fallback)" + title="Utilisé si Saxo devient indisponible pour ce ticker — format yfinance, ex. EURCHF=X" + className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-44 focus:outline-none focus:border-blue-500/50" + /> + +
+ )} {testQuote.isError && (
✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'}
)} + {addMsg &&
{addMsg}
}
) diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 664215c..b2c784e 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -1390,14 +1390,14 @@ export default function StrategyBuilder() { )} - {mode === 'build' && priceMutation.isPending &&
Calcul en cours…
} - {mode === 'build' && priceMutation.isError && ( + {(mode === 'build' || mode === 'derive') && priceMutation.isPending &&
Calcul en cours…
} + {(mode === 'build' || mode === 'derive') && priceMutation.isError && (
Erreur de pricing — vérifiez les jambes sélectionnées.
)} - {mode === 'build' && priced && ( + {(mode === 'build' || mode === 'derive') && priced && ( <>