feat: saxo price
This commit is contained in:
@@ -17,6 +17,7 @@ from services.instrument_service import (
|
|||||||
update_instrument_drivers,
|
update_instrument_drivers,
|
||||||
update_instrument_saxo_link,
|
update_instrument_saxo_link,
|
||||||
quick_add_instrument_from_watchlist,
|
quick_add_instrument_from_watchlist,
|
||||||
|
add_manual_instrument,
|
||||||
)
|
)
|
||||||
|
|
||||||
class DriverUpdate(BaseModel):
|
class DriverUpdate(BaseModel):
|
||||||
@@ -30,6 +31,14 @@ class SaxoLinkBody(BaseModel):
|
|||||||
class QuickAddBody(BaseModel):
|
class QuickAddBody(BaseModel):
|
||||||
ticker: str
|
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"])
|
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))
|
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")
|
@router.get("/{instrument_id}/snapshot")
|
||||||
async def instrument_snapshot(
|
async def instrument_snapshot(
|
||||||
instrument_id: str,
|
instrument_id: str,
|
||||||
|
|||||||
@@ -227,6 +227,34 @@ def quick_add_instrument_from_watchlist(ticker: str) -> Dict[str, Any]:
|
|||||||
return {"id": uid, "created": True}
|
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 ──────────────────────────────────────────────────────────
|
# ── DataFrame helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
def _ohlcv_to_df(records: List[Dict]) -> pd.DataFrame:
|
||||||
|
|||||||
@@ -211,6 +211,15 @@ export const useQuickAddInstrument = () =>
|
|||||||
api.post('/instruments/quick-add', { ticker }).then(r => r.data as { id: string; created: boolean }),
|
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
|
// Free-form display name — no longer tied to yfinance's long_name lookup, since an
|
||||||
// instrument can now be entirely Saxo-sourced.
|
// instrument can now be entirely Saxo-sourced.
|
||||||
export const useRenameWatchlistInstrument = () => {
|
export const useRenameWatchlistInstrument = () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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, 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 { 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'
|
||||||
import SaxoLinkPicker from '../components/SaxoLinkPicker'
|
import SaxoLinkPicker from '../components/SaxoLinkPicker'
|
||||||
@@ -33,6 +33,21 @@ const SOURCE_DOCS: Record<string, { description: string; link?: string; cost: st
|
|||||||
twitter_trump: { description: 'X/Twitter Trump feed — speeches, tariff announcements', cost: 'Paid API ($100/mo+)' },
|
twitter_trump: { description: 'X/Twitter Trump feed — speeches, tariff announcements', cost: 'Paid API ($100/mo+)' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors InstrumentDashboard.tsx's CATEGORY_LABELS (kept in sync manually — a small,
|
||||||
|
// stable list, not worth sharing across the two pages).
|
||||||
|
const INSTRUMENT_CATEGORIES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'fx', label: 'Forex' },
|
||||||
|
{ value: 'equity_index', label: 'Indices US' },
|
||||||
|
{ value: 'equity_intl', label: 'Intl Equity' },
|
||||||
|
{ value: 'metal', label: 'Métaux' },
|
||||||
|
{ value: 'energy', label: 'Énergie' },
|
||||||
|
{ value: 'bond', label: 'Obligataire' },
|
||||||
|
{ value: 'credit', label: 'Crédit' },
|
||||||
|
{ value: 'volatility', label: 'Volatilité' },
|
||||||
|
{ value: 'stock', label: 'Actions' },
|
||||||
|
{ value: 'crypto', label: 'Crypto' },
|
||||||
|
]
|
||||||
|
|
||||||
// ── Risk Profiles Component ───────────────────────────────────────────────────
|
// ── Risk Profiles Component ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROFILE_COLORS = [
|
const PROFILE_COLORS = [
|
||||||
@@ -501,6 +516,11 @@ function SaxoConnectionCard() {
|
|||||||
const testQuote = useTestSaxoQuote()
|
const testQuote = useTestSaxoQuote()
|
||||||
const [quoteSymbol, setQuoteSymbol] = useState('EURUSD')
|
const [quoteSymbol, setQuoteSymbol] = useState('EURUSD')
|
||||||
const [quoteAssetType, setQuoteAssetType] = useState('FxSpot')
|
const [quoteAssetType, setQuoteAssetType] = useState('FxSpot')
|
||||||
|
const addManual = useAddManualInstrument()
|
||||||
|
const [addName, setAddName] = useState('')
|
||||||
|
const [addCategory, setAddCategory] = useState('fx')
|
||||||
|
const [addYfTicker, setAddYfTicker] = useState('')
|
||||||
|
const [addMsg, setAddMsg] = useState('')
|
||||||
const expandWatchlist = useExpandSaxoWatchlist()
|
const expandWatchlist = useExpandSaxoWatchlist()
|
||||||
const [expandKeyword, setExpandKeyword] = useState('')
|
const [expandKeyword, setExpandKeyword] = useState('')
|
||||||
const [expandMsg, setExpandMsg] = useState('')
|
const [expandMsg, setExpandMsg] = useState('')
|
||||||
@@ -562,6 +582,34 @@ function SaxoConnectionCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefill the "add to Instrument Analysis" fields once a test resolves — the user can
|
||||||
|
// still override name/category/yf_ticker before actually adding it.
|
||||||
|
useEffect(() => {
|
||||||
|
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 () => {
|
const handleSnapshotAllNow = async () => {
|
||||||
setSnapMsg('')
|
setSnapMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -772,13 +820,13 @@ function SaxoConnectionCard() {
|
|||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-700/30">
|
<div className="mt-4 pt-3 border-t border-slate-700/30">
|
||||||
<div className="text-xs text-slate-500 mb-2">
|
<div className="text-xs text-slate-500 mb-2">
|
||||||
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) :
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={quoteSymbol}
|
value={quoteSymbol}
|
||||||
onChange={e => setQuoteSymbol(e.target.value.toUpperCase())}
|
onChange={e => 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"
|
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"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
@@ -801,16 +849,49 @@ function SaxoConnectionCard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{testQuote.isSuccess && (
|
{testQuote.isSuccess && (
|
||||||
<div className="text-xs text-emerald-400 mt-2">
|
<>
|
||||||
✓ {testQuote.data.symbol} ({testQuote.data.description}) — Bid {testQuote.data.bid} / Ask {testQuote.data.ask}
|
<div className="text-xs text-emerald-400 mt-2">
|
||||||
<span className="text-slate-600"> · {testQuote.data.price_source}</span>
|
✓ {testQuote.data.symbol} ({testQuote.data.description}) — Bid {testQuote.data.bid} / Ask {testQuote.data.ask}
|
||||||
</div>
|
<span className="text-slate-600"> · {testQuote.data.price_source}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
value={addName}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={addCategory}
|
||||||
|
onChange={e => setAddCategory(e.target.value)}
|
||||||
|
className="bg-dark-800 border border-slate-700/40 rounded px-2 py-1.5 text-xs text-white"
|
||||||
|
>
|
||||||
|
{INSTRUMENT_CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={addYfTicker}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddManual}
|
||||||
|
disabled={addManual.isPending}
|
||||||
|
title="Ajoute ce ticker à Instrument Analysis, lié à Saxo — pas besoin de toucher instruments.json"
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Ajouter à Instrument Analysis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{testQuote.isError && (
|
{testQuote.isError && (
|
||||||
<div className="text-xs text-red-400 mt-2">
|
<div className="text-xs text-red-400 mt-2">
|
||||||
✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'}
|
✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{addMsg && <div className="text-xs mt-2" style={{ color: addMsg.startsWith('✓') ? '#34d399' : '#f87171' }}>{addMsg}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1390,14 +1390,14 @@ export default function StrategyBuilder() {
|
|||||||
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
|
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'build' && priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
|
{(mode === 'build' || mode === 'derive') && priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
|
||||||
{mode === 'build' && priceMutation.isError && (
|
{(mode === 'build' || mode === 'derive') && priceMutation.isError && (
|
||||||
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
||||||
Erreur de pricing — vérifiez les jambes sélectionnées.
|
Erreur de pricing — vérifiez les jambes sélectionnées.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === 'build' && priced && (
|
{(mode === 'build' || mode === 'derive') && priced && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<div className="card-sm">
|
<div className="card-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user