2197 lines
110 KiB
TypeScript
2197 lines
110 KiB
TypeScript
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, useValidateSaxoWatchlist, useSaxoCatalog, useSaxoCatalogSummary, useRefreshSaxoCatalog, useTestSaxoQuote, useSaxoSettings, useUpdateSaxoSettings, useSnapshotAllSaxoNow, useExpandSaxoWatchlist, 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 = ''
|
||
|
||
const SOURCE_CATEGORIES = {
|
||
'Active RSS Feeds': ['reuters_world', 'reuters_business', 'reuters_energy', 'ap_top', 'aljazeera', 'ft', 'bloomberg'],
|
||
'Economic Data': ['newsapi', 'gdelt', 'eia', 'fred', 'usda'],
|
||
'Health & Disasters': ['who', 'emdat'],
|
||
'Social Media': ['twitter_trump'],
|
||
}
|
||
|
||
const SOURCE_DOCS: Record<string, { description: string; link?: string; cost: string }> = {
|
||
reuters_world: { description: 'Reuters World News', cost: 'Free' },
|
||
reuters_business: { description: 'Reuters Business & Markets', cost: 'Free' },
|
||
reuters_energy: { description: 'Reuters Energy & Commodities', cost: 'Free' },
|
||
ap_top: { description: 'Associated Press — Top Stories', cost: 'Free' },
|
||
aljazeera: { description: 'Al Jazeera — Middle East/World coverage', cost: 'Free' },
|
||
ft: { description: 'Financial Times — international finance', cost: 'Free' },
|
||
bloomberg: { description: 'Bloomberg Markets RSS', cost: 'Free' },
|
||
newsapi: { description: '100 req/day free — multi-source world news', link: 'https://newsapi.org', cost: '100 req/d free' },
|
||
gdelt: { description: 'Global geopolitical database — 300K events/day', link: 'https://gdeltproject.org', cost: 'Totally free' },
|
||
eia: { description: 'US Energy Information Administration — weekly oil/gas data', link: 'https://www.eia.gov/opendata', cost: 'Free with key' },
|
||
fred: { description: 'Federal Reserve St. Louis — US macro (GDP, inflation, employment)', link: 'https://fred.stlouisfed.org/docs/api/fred/', cost: 'Free with key' },
|
||
usda: { description: 'USDA — official US agricultural reports', cost: 'Free' },
|
||
who: { description: 'WHO — global health alerts RSS', cost: 'Free' },
|
||
emdat: { description: 'EM-DAT — natural disasters database', link: 'https://www.emdat.be', cost: 'Free registration' },
|
||
twitter_trump: { description: 'X/Twitter Trump feed — speeches, tariff announcements', cost: 'Paid API ($100/mo+)' },
|
||
}
|
||
|
||
// ── Risk Profiles Component ───────────────────────────────────────────────────
|
||
|
||
const PROFILE_COLORS = [
|
||
{ value: '#22c55e', label: 'Green' },
|
||
{ value: '#3b82f6', label: 'Blue' },
|
||
{ value: '#f97316', label: 'Orange' },
|
||
{ value: '#ef4444', label: 'Red' },
|
||
{ value: '#8b5cf6', label: 'Purple' },
|
||
{ value: '#eab308', label: 'Yellow' },
|
||
]
|
||
|
||
function NextRunCountdown({ nextRunAt }: { nextRunAt: string }) {
|
||
const [remaining, setRemaining] = useState('')
|
||
|
||
useEffect(() => {
|
||
function update() {
|
||
const diff = new Date(nextRunAt + 'Z').getTime() - Date.now()
|
||
if (diff <= 0) { setRemaining('Imminent…'); return }
|
||
const h = Math.floor(diff / 3_600_000)
|
||
const m = Math.floor((diff % 3_600_000) / 60_000)
|
||
const s = Math.floor((diff % 60_000) / 1_000)
|
||
setRemaining(h > 0 ? `${h}h ${m.toString().padStart(2,'0')}min` : `${m}min ${s.toString().padStart(2,'0')}s`)
|
||
}
|
||
update()
|
||
const id = setInterval(update, 1_000)
|
||
return () => clearInterval(id)
|
||
}, [nextRunAt])
|
||
|
||
const absTime = new Date(nextRunAt + 'Z').toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||
|
||
return (
|
||
<div className="flex items-center gap-2 text-emerald-400/80">
|
||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||
<span>Next cycle in <span className="font-mono font-semibold text-emerald-300">{remaining}</span></span>
|
||
<span className="text-slate-600">({absTime} local time)</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function evNetLabel(evNet: number): { text: string; cls: string } {
|
||
if (evNet > 0.05) return { text: `Net EV +${(evNet * 100).toFixed(0)}%`, cls: 'text-emerald-400' }
|
||
if (evNet >= -0.01) return { text: 'Net EV ≈ 0', cls: 'text-yellow-400' }
|
||
return { text: `Net EV ${(evNet * 100).toFixed(0)}%`, cls: 'text-slate-600' }
|
||
}
|
||
|
||
function ProfileRow({
|
||
profile,
|
||
onSave,
|
||
onDelete,
|
||
}: {
|
||
profile: any
|
||
onSave: (p: any) => void
|
||
onDelete: (id: number) => void
|
||
}) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [name, setName] = useState(profile.name)
|
||
const [score, setScore] = useState(profile.min_score)
|
||
const [gain, setGain] = useState(profile.min_gain_pct)
|
||
const [color, setColor] = useState(profile.color ?? '#3b82f6')
|
||
const [enabled, setEnabled] = useState(profile.enabled !== 0)
|
||
|
||
// Live EV computation
|
||
const p = score / 100
|
||
const G = gain / 100
|
||
const denom = p * G + (1 - p)
|
||
const ev_net = p * G - (1 - p)
|
||
const trade_score = denom > 0 ? (p * G / denom * 100) : 0
|
||
const { text: evText, cls: evCls } = evNetLabel(ev_net)
|
||
|
||
const save = () => {
|
||
onSave({ id: profile.id, name, min_score: score, min_gain_pct: gain, color, enabled, sort_order: profile.sort_order ?? 0 })
|
||
setEditing(false)
|
||
}
|
||
|
||
if (!editing) {
|
||
const { text: fev, cls: fevc } = evNetLabel(profile.ev_net_at_frontier ?? 0)
|
||
return (
|
||
<div className="flex items-center gap-3 py-2 px-3 rounded hover:bg-dark-700/40 group">
|
||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: profile.color ?? '#3b82f6' }} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className={clsx('text-sm font-semibold', enabled ? 'text-slate-200' : 'text-slate-600')}>{profile.name}</span>
|
||
{!enabled && <span className="text-[10px] text-slate-700 italic">disabled</span>}
|
||
<span className="text-xs text-slate-500">Score ≥ <span className="text-slate-300 font-mono">{profile.min_score}</span></span>
|
||
<span className="text-xs text-slate-500">Gain ≥ <span className="text-slate-300 font-mono">{profile.min_gain_pct}%</span></span>
|
||
<span className={clsx('text-[11px] font-mono', fevc)}>{fev}</span>
|
||
<span className="text-[11px] text-slate-600">Trade Score ≥ {(profile.trade_score_at_frontier ?? 0).toFixed(0)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||
<button onClick={() => setEditing(true)}
|
||
className="text-slate-600 hover:text-slate-300 p-1 rounded">
|
||
<Pencil className="w-3 h-3" />
|
||
</button>
|
||
<button onClick={() => onDelete(profile.id)}
|
||
className="text-slate-600 hover:text-red-400 p-1 rounded">
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="bg-dark-700/60 rounded-lg p-3 border border-slate-600/40 space-y-3">
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Profile name</label>
|
||
<input value={name} onChange={e => setName(e.target.value)}
|
||
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white" />
|
||
</div>
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{score}</span>/100</label>
|
||
<input type="range" min="0" max="90" step="5" value={score}
|
||
onChange={e => setScore(parseInt(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
</div>
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{gain}%</span></label>
|
||
<input type="range" min="10" max="2000" step="10" value={gain}
|
||
onChange={e => setGain(parseFloat(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Live EV preview */}
|
||
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
|
||
<span className="text-slate-500">At the frontier (score={score}, gain={gain}%)</span>
|
||
<span className={clsx('font-mono font-bold', evCls)}>{evText}</span>
|
||
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{trade_score.toFixed(1)}</span>/100</span>
|
||
<span className="text-slate-600">
|
||
Score min EV=0 : <span className="text-slate-400 font-mono">{Math.ceil(100 / (G + 1))}</span>/100
|
||
</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex gap-1.5">
|
||
{PROFILE_COLORS.map(c => (
|
||
<button key={c.value} onClick={() => setColor(c.value)}
|
||
title={c.label}
|
||
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
|
||
color === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
|
||
style={{ background: c.value }} />
|
||
))}
|
||
</div>
|
||
<button onClick={() => setEnabled(!enabled)}
|
||
className={clsx('text-xs px-2 py-0.5 rounded border transition-all',
|
||
enabled ? 'border-emerald-600/40 text-emerald-400 bg-emerald-900/20' : 'border-slate-700 text-slate-600')}>
|
||
{enabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
<div className="ml-auto flex gap-2">
|
||
<button onClick={() => setEditing(false)}
|
||
className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Cancel</button>
|
||
<button onClick={save}
|
||
className="text-xs bg-blue-600 hover:bg-blue-500 text-white px-3 py-1 rounded font-semibold">
|
||
Save
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RiskProfilesCard() {
|
||
const { data: profileData, isLoading } = useRiskProfiles()
|
||
const { mutate: upsert } = useUpsertProfile()
|
||
const { mutate: del } = useDeleteProfile()
|
||
const [showNew, setShowNew] = useState(false)
|
||
const [newName, setNewName] = useState('')
|
||
const [newScore, setNewScore] = useState(30)
|
||
const [newGain, setNewGain] = useState(200)
|
||
const [newColor, setNewColor] = useState('#3b82f6')
|
||
|
||
const profiles: any[] = (profileData as any)?.profiles ?? []
|
||
|
||
// Live preview for new profile
|
||
const np = newScore / 100
|
||
const nG = newGain / 100
|
||
const nDenom = np * nG + (1 - np)
|
||
const nEvNet = np * nG - (1 - np)
|
||
const nTradeScore = nDenom > 0 ? (np * nG / nDenom * 100) : 0
|
||
const nMinScore = Math.ceil(100 / (nG + 1))
|
||
|
||
const saveNew = () => {
|
||
if (!newName.trim()) return
|
||
upsert({ name: newName, min_score: newScore, min_gain_pct: newGain, color: newColor, enabled: true, sort_order: profiles.length })
|
||
setShowNew(false)
|
||
setNewName('')
|
||
setNewScore(30)
|
||
setNewGain(200)
|
||
}
|
||
|
||
return (
|
||
<div className="card bg-dark-700/20 mb-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-slate-300">Risk Profiles</div>
|
||
<div className="text-[10px] text-slate-600 mt-0.5">
|
||
A trade is logged in the journal if it passes <span className="text-slate-500">at least one</span> enabled profile
|
||
· Formula: Net EV = (score/100 × gain/100) − (1 − score/100)
|
||
</div>
|
||
</div>
|
||
<button onClick={() => setShowNew(true)}
|
||
className="flex items-center gap-1 text-xs border border-blue-500/40 text-blue-400 hover:bg-blue-900/20 px-2.5 py-1 rounded">
|
||
<Plus className="w-3 h-3" /> New profile
|
||
</button>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="h-16 animate-pulse bg-dark-700 rounded" />
|
||
) : profiles.length === 0 ? (
|
||
<div className="text-center py-6 text-slate-600 text-sm">No profiles — all trades will be ignored</div>
|
||
) : (
|
||
<div className="space-y-1">
|
||
{profiles.map((p: any) => (
|
||
<ProfileRow key={p.id} profile={p}
|
||
onSave={data => upsert(data)}
|
||
onDelete={id => del(id)} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* New profile form */}
|
||
{showNew && (
|
||
<div className="bg-dark-700/60 rounded-lg p-3 border border-blue-700/30 space-y-3 mt-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-blue-400">New profile</span>
|
||
<button onClick={() => setShowNew(false)} className="text-slate-600 hover:text-slate-400"><X className="w-3.5 h-3.5" /></button>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Name</label>
|
||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Ultra-aggressive"
|
||
className="w-full bg-dark-800 border border-slate-700 rounded px-2 py-1 text-sm text-white placeholder:text-slate-700" />
|
||
</div>
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Score min = <span className="text-blue-400 font-mono">{newScore}</span></label>
|
||
<input type="range" min="0" max="90" step="5" value={newScore}
|
||
onChange={e => setNewScore(parseInt(e.target.value))} className="w-full accent-blue-500" />
|
||
</div>
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1 block">Gain min = <span className="text-blue-400 font-mono">{newGain}%</span></label>
|
||
<input type="range" min="10" max="2000" step="10" value={newGain}
|
||
onChange={e => setNewGain(parseFloat(e.target.value))} className="w-full accent-blue-500" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Live math */}
|
||
<div className="bg-dark-800 rounded px-3 py-2 text-xs flex items-center gap-4 flex-wrap">
|
||
<span className={clsx('font-mono font-bold', evNetLabel(nEvNet).cls)}>{evNetLabel(nEvNet).text}</span>
|
||
<span className="text-slate-600">Trade Score = <span className="text-slate-400">{nTradeScore.toFixed(1)}</span>/100</span>
|
||
<span className="text-slate-700">Min score for EV=0 with gain {newGain}% : <span className="text-slate-500 font-mono">{nMinScore}</span>/100</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex gap-1.5">
|
||
{PROFILE_COLORS.map(c => (
|
||
<button key={c.value} onClick={() => setNewColor(c.value)} title={c.label}
|
||
className={clsx('w-5 h-5 rounded-full border-2 transition-all',
|
||
newColor === c.value ? 'border-white scale-110' : 'border-transparent opacity-60 hover:opacity-100')}
|
||
style={{ background: c.value }} />
|
||
))}
|
||
</div>
|
||
<div className="ml-auto flex gap-2">
|
||
<button onClick={() => setShowNew(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2 py-1">Cancel</button>
|
||
<button onClick={saveNew} disabled={!newName.trim()}
|
||
className="text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-3 py-1 rounded font-semibold">
|
||
Create
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Frontier visualization */}
|
||
{profiles.length > 0 && (
|
||
<div className="mt-3 pt-3 border-t border-slate-700/30">
|
||
<div className="text-[10px] text-slate-600 mb-2">Acceptance frontier — each point represents the minimum required per profile</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{profiles.filter((p: any) => p.enabled).map((p: any) => {
|
||
const { text: ev, cls } = evNetLabel(p.ev_net_at_frontier ?? 0)
|
||
return (
|
||
<div key={p.id} className="flex items-center gap-2 bg-dark-700 rounded px-2.5 py-1.5 text-xs">
|
||
<div className="w-2 h-2 rounded-full" style={{ background: p.color }} />
|
||
<span className="text-slate-400 font-semibold">{p.name}</span>
|
||
<span className="font-mono text-slate-500">{p.min_score}pts</span>
|
||
<span className="text-slate-600">×</span>
|
||
<span className="font-mono text-slate-500">{p.min_gain_pct}%</span>
|
||
<span className={clsx('font-mono text-[10px]', cls)}>→ {ev}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WatchlistCard() {
|
||
const { data: items, isLoading } = useInstrumentsWatchlist()
|
||
const { mutateAsync: addTicker, isPending: adding } = useAddWatchlistInstrument()
|
||
const { mutate: removeTicker } = useRemoveWatchlistInstrument()
|
||
const [input, setInput] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
const handleAdd = async () => {
|
||
const sym = input.trim().toUpperCase()
|
||
if (!sym) return
|
||
setError('')
|
||
const check = await validateTicker(sym)
|
||
if (!check.valid) {
|
||
setError(check.reason ?? 'Ticker not found')
|
||
return
|
||
}
|
||
try {
|
||
await addTicker(sym)
|
||
setInput('')
|
||
} catch (e: unknown) {
|
||
const msg = (e as any)?.response?.data?.detail ?? (e as { message?: string })?.message ?? 'Could not add ticker'
|
||
setError(msg)
|
||
}
|
||
}
|
||
|
||
const list: any[] = items ?? []
|
||
|
||
return (
|
||
<div className="card bg-dark-700/20 mb-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-slate-300 flex items-center gap-1.5">
|
||
<Radar className="w-4 h-4 text-blue-400" /> Instruments Watchlist
|
||
</div>
|
||
<div className="text-[10px] text-slate-600 mt-0.5">
|
||
Instruments surveillés pour le radar du Dashboard et les highlights Options Lab — liste indépendante des autres watchlists (Markets, Options Lab, InstrumentModels).
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<input
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||
placeholder="Ticker (ex. AAPL, EURUSD=X, ^GSPC)"
|
||
className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-64 focus:outline-none focus:border-blue-500/50"
|
||
/>
|
||
<button onClick={handleAdd} disabled={adding || !input.trim()}
|
||
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 transition-colors">
|
||
<Plus className="w-3.5 h-3.5" /> Add
|
||
</button>
|
||
</div>
|
||
{error && <div className="text-[10px] text-red-400 mb-3">{error}</div>}
|
||
|
||
{isLoading ? (
|
||
<div className="text-xs text-slate-600">Loading…</div>
|
||
) : list.length === 0 ? (
|
||
<div className="text-xs text-slate-600">No instrument watched yet.</div>
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{list.map(item => (
|
||
<div key={item.ticker} className="flex items-center justify-between bg-dark-800/60 rounded px-3 py-1.5">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<span className="text-xs font-mono font-bold text-white">{item.ticker}</span>
|
||
<span className="text-[10px] text-slate-500 truncate">{item.name}</span>
|
||
<span className="text-[9px] text-slate-700 bg-dark-700 px-1.5 py-0.5 rounded capitalize">{item.asset_class}</span>
|
||
</div>
|
||
<button onClick={() => removeTicker(item.ticker)} className="text-slate-600 hover:text-red-400 transition-colors">
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Saxo OpenAPI connection ────────────────────────────────────────────────────
|
||
|
||
function timeUntil(iso?: string): string {
|
||
if (!iso) return ''
|
||
const ms = new Date(iso).getTime() - Date.now()
|
||
if (ms <= 0) return 'expiré'
|
||
const min = Math.round(ms / 60000)
|
||
return min < 60 ? `${min} min` : `${(min / 60).toFixed(1)} h`
|
||
}
|
||
|
||
function SaxoConnectionCard() {
|
||
const qc = useQueryClient()
|
||
const { data: status } = useSaxoStatus()
|
||
const { data: watchlistData } = useSaxoWatchlist()
|
||
const disconnect = useDisconnectSaxo()
|
||
const updateWatchlist = useUpdateSaxoWatchlist()
|
||
const snapshotNow = useSnapshotSaxoNow()
|
||
const { data: validation, refetch: runValidation, isFetching: validating } = useValidateSaxoWatchlist()
|
||
const { data: catalogSummary } = useSaxoCatalogSummary()
|
||
const refreshCatalog = useRefreshSaxoCatalog()
|
||
const { data: settings } = useSaxoSettings()
|
||
const updateSettings = useUpdateSaxoSettings()
|
||
const snapshotAllNow = useSnapshotAllSaxoNow()
|
||
const [input, setInput] = useState('')
|
||
const [snapMsg, setSnapMsg] = useState('')
|
||
const { data: catalogMatches } = useSaxoCatalog(undefined, input.length >= 2 ? input : undefined)
|
||
const testQuote = useTestSaxoQuote()
|
||
const [quoteSymbol, setQuoteSymbol] = useState('EURUSD')
|
||
const [quoteAssetType, setQuoteAssetType] = useState('FxSpot')
|
||
const expandWatchlist = useExpandSaxoWatchlist()
|
||
const [expandKeyword, setExpandKeyword] = useState('')
|
||
const [expandMsg, setExpandMsg] = useState('')
|
||
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search)
|
||
if (params.has('saxo_connected') || params.has('saxo_error')) {
|
||
qc.invalidateQueries({ queryKey: ['saxo-status'] })
|
||
setSnapMsg(params.has('saxo_connected') ? '✓ Connecté à Saxo' : "✗ Échec de la connexion Saxo — vérifiez l'App Secret dans backend/.env")
|
||
params.delete('saxo_connected'); params.delete('saxo_error')
|
||
const qs = params.toString()
|
||
window.history.replaceState({}, '', window.location.pathname + (qs ? `?${qs}` : ''))
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const symbols = watchlistData?.symbols ?? []
|
||
|
||
const addSymbol = () => {
|
||
const sym = input.trim().toUpperCase()
|
||
if (!sym || symbols.includes(sym)) return
|
||
updateWatchlist.mutate([...symbols, sym])
|
||
setInput('')
|
||
}
|
||
|
||
// Guarded by isPending everywhere it's called from the UI: each call captures `symbols`
|
||
// from the current render, so firing a second mutation before the first one's
|
||
// invalidateQueries/refetch completes reads a stale list and undoes the first removal
|
||
// once it resolves — confirmed as the cause of rapid multi-click removals "coming back".
|
||
const removeSymbol = (sym: string) => updateWatchlist.mutate(symbols.filter(s => s !== sym))
|
||
const clearAllSymbols = () => {
|
||
if (symbols.length === 0) return
|
||
if (!window.confirm(`Effacer les ${symbols.length} ticker(s) de la watchlist Saxo ?`)) return
|
||
updateWatchlist.mutate([])
|
||
}
|
||
|
||
const handleExpandWatchlist = async () => {
|
||
const kw = expandKeyword.trim()
|
||
if (!kw) return
|
||
setExpandMsg('')
|
||
try {
|
||
const r = await expandWatchlist.mutateAsync(kw)
|
||
setExpandMsg(r.added.length
|
||
? `✓ ${r.added.length} ticker(s) ajouté(s) : ${r.added.join(', ')}`
|
||
: `○ Aucun nouveau ticker pour "${kw}" (déjà présents, ou rafraîchissez le catalogue)`)
|
||
setExpandKeyword('')
|
||
} catch (e: any) {
|
||
setExpandMsg(`✗ ${e?.response?.data?.detail ?? 'échec'}`)
|
||
}
|
||
}
|
||
|
||
const handleSnapshotNow = async (sym: string) => {
|
||
setSnapMsg('')
|
||
try {
|
||
const r = await snapshotNow.mutateAsync(sym)
|
||
setSnapMsg(`✓ ${sym} — ${r.rows_saved} lignes enregistrées`)
|
||
} catch (e: any) {
|
||
setSnapMsg(`✗ ${sym} — ${e?.response?.data?.detail ?? 'échec'}`)
|
||
}
|
||
}
|
||
|
||
const handleSnapshotAllNow = async () => {
|
||
setSnapMsg('')
|
||
try {
|
||
const r = await snapshotAllNow.mutateAsync()
|
||
const total = Object.values(r.results).reduce((a, b) => a + b, 0)
|
||
setSnapMsg(`✓ Watchlist entière — ${total} lignes enregistrées (${Object.keys(r.results).length} symboles)`)
|
||
} catch (e: any) {
|
||
setSnapMsg(`✗ ${e?.response?.data?.detail ?? 'échec'}`)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="section-title mb-0 flex items-center gap-1">
|
||
<Link2 className="w-3 h-3" /> Saxo OpenAPI ({(status?.environment ?? 'sim').toUpperCase()})
|
||
</div>
|
||
<span className={clsx('badge', status?.connected ? 'badge-green' : 'badge-red')}>
|
||
{status?.connected ? 'Connecté' : 'Déconnecté'}
|
||
</span>
|
||
</div>
|
||
|
||
{!status?.configured && (
|
||
<div className="text-xs text-amber-400 bg-amber-900/10 border border-amber-700/30 rounded px-3 py-2 mb-3">
|
||
SAXO_APP_KEY / SAXO_APP_SECRET manquants dans backend/.env — ajoutez-les puis redémarrez le backend.
|
||
</div>
|
||
)}
|
||
|
||
{status?.connected && (
|
||
<div className="text-xs text-slate-500 mb-3 space-y-0.5">
|
||
<div>Token valide encore <span className="text-slate-300">{timeUntil(status.expires_at)}</span></div>
|
||
<div>Rafraîchi automatiquement en tâche de fond — pas besoin de te reconnecter.</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-2 mb-4">
|
||
{status?.connected ? (
|
||
<button
|
||
onClick={() => disconnect.mutate()}
|
||
disabled={disconnect.isPending}
|
||
className="flex items-center gap-1.5 bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-3 py-1.5 rounded text-xs font-semibold disabled:opacity-40"
|
||
>
|
||
<Unlink className="w-3.5 h-3.5" /> Déconnecter
|
||
</button>
|
||
) : (
|
||
<a
|
||
href="/oauth/saxo/login"
|
||
className={clsx(
|
||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-semibold text-white',
|
||
status?.configured ? 'bg-blue-600 hover:bg-blue-500' : 'bg-dark-700 opacity-40 pointer-events-none',
|
||
)}
|
||
>
|
||
<Link2 className="w-3.5 h-3.5" /> Se connecter à Saxo
|
||
</a>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="text-xs text-slate-500 flex items-center gap-2">
|
||
<span>Watchlist snapshotée toutes les</span>
|
||
<input
|
||
type="number" min={1} max={120}
|
||
value={settings?.snapshot_minutes ?? ''}
|
||
onChange={e => updateSettings.mutate(parseFloat(e.target.value) || 5)}
|
||
className="w-14 bg-dark-800 border border-slate-700/40 rounded px-1.5 py-0.5 text-xs text-white text-center"
|
||
/>
|
||
<span>min (historique Date/Spot/Expiry/Strike/Bid/Ask/Mid/VolatilityPct/Greeks) :</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
onClick={handleSnapshotAllNow}
|
||
disabled={!status?.connected || snapshotAllNow.isPending}
|
||
title="Snapshot immédiat de toute la watchlist, sans attendre le prochain cycle"
|
||
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"
|
||
>
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', snapshotAllNow.isPending && 'animate-spin')} /> Rafraîchir maintenant
|
||
</button>
|
||
<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 justify-between mb-2">
|
||
<div className="text-[10px] text-slate-600">
|
||
Catalogue local (Futures/FX — tickers Saxo non-évidents, ex. Or = <code>OG:xcme</code>) :{' '}
|
||
{(catalogSummary ?? []).map(s => `${s.asset_type} (${s.count})`).join(' · ') || 'vide'}
|
||
</div>
|
||
<button
|
||
onClick={() => refreshCatalog.mutate(undefined)}
|
||
disabled={!status?.connected || refreshCatalog.isPending}
|
||
className="flex items-center gap-1 text-[10px] text-slate-400 hover:text-slate-200 border border-slate-700/50 px-2 py-1 rounded disabled:opacity-40"
|
||
>
|
||
<RefreshCw className={clsx('w-3 h-3', refreshCatalog.isPending && 'animate-spin')} /> Rafraîchir le catalogue
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<input
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && addSymbol()}
|
||
placeholder="Symbole (ex. SPY, ou tape pour chercher dans le catalogue)"
|
||
list="saxo-catalog-datalist"
|
||
className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-72 focus:outline-none focus:border-blue-500/50"
|
||
/>
|
||
<datalist id="saxo-catalog-datalist">
|
||
{(catalogMatches ?? []).map(m => (
|
||
<option key={m.uic} value={m.symbol}>{m.description} ({m.asset_type})</option>
|
||
))}
|
||
</datalist>
|
||
<button onClick={addSymbol} disabled={!input.trim() || updateWatchlist.isPending}
|
||
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
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<input
|
||
value={expandKeyword}
|
||
onChange={e => setExpandKeyword(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleExpandWatchlist()}
|
||
placeholder="Mot-clé sous-jacent (ex. EURUSD, Gold, Brent)"
|
||
className="bg-dark-800 border border-slate-700/40 rounded px-3 py-1.5 text-xs text-white w-72 focus:outline-none focus:border-blue-500/50"
|
||
/>
|
||
<button
|
||
onClick={handleExpandWatchlist}
|
||
disabled={!expandKeyword.trim() || expandWatchlist.isPending}
|
||
title="Ajoute toutes les maturités/racines du catalogue correspondant à ce mot-clé (pas besoin de connaître chaque ticker exact)"
|
||
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 disabled:opacity-40"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" /> Ajouter toutes les maturités
|
||
</button>
|
||
</div>
|
||
{expandMsg && <div className="text-[10px] text-slate-500 mb-2">{expandMsg}</div>}
|
||
{symbols.length > 0 && (
|
||
<div className="flex justify-end mb-1.5">
|
||
<button onClick={clearAllSymbols} disabled={updateWatchlist.isPending}
|
||
title="Retire tous les tickers de la watchlist en un seul appel"
|
||
className="flex items-center gap-1 px-2 py-1 rounded text-[10px] border border-red-700/40 text-red-400 hover:bg-red-900/20 disabled:opacity-40">
|
||
<Trash2 className="w-3 h-3" /> Tout effacer ({symbols.length})
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{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
|
||
? `Résolu: ${check.matched_symbol ?? '?'} — ${check.description ?? ''} (${check.asset_type ?? '?'}, Uic ${check.uic})`
|
||
: 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)} disabled={updateWatchlist.isPending} className="disabled:opacity-40">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
{snapMsg && <div className="text-[10px] text-slate-500 mt-2">{snapMsg}</div>}
|
||
|
||
<div className="mt-4 pt-3 border-t border-slate-700/30">
|
||
<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) :
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={quoteSymbol}
|
||
onChange={e => setQuoteSymbol(e.target.value.toUpperCase())}
|
||
placeholder="EURUSD"
|
||
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
|
||
value={quoteAssetType}
|
||
onChange={e => setQuoteAssetType(e.target.value)}
|
||
className="bg-dark-800 border border-slate-700/40 rounded px-2 py-1.5 text-xs text-white"
|
||
>
|
||
<option value="FxSpot">FxSpot</option>
|
||
<option value="Stock">Stock</option>
|
||
<option value="StockIndex">StockIndex</option>
|
||
<option value="ContractFutures">ContractFutures</option>
|
||
</select>
|
||
<button
|
||
onClick={() => testQuote.mutate({ symbol: quoteSymbol, assetType: quoteAssetType })}
|
||
disabled={!status?.connected || !quoteSymbol.trim() || testQuote.isPending}
|
||
className="flex items-center gap-1 px-3 py-1.5 rounded text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 disabled:opacity-40"
|
||
>
|
||
<RefreshCw className={clsx('w-3.5 h-3.5', testQuote.isPending && 'animate-spin')} /> Tester
|
||
</button>
|
||
</div>
|
||
{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}
|
||
<span className="text-slate-600"> · {testQuote.data.price_source}</span>
|
||
</div>
|
||
)}
|
||
{testQuote.isError && (
|
||
<div className="text-xs text-red-400 mt-2">
|
||
✗ {(testQuote.error as any)?.response?.data?.detail ?? 'échec'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Cycle step config — generic renderer driven by GET /api/cycle/step-catalog ─
|
||
// Same declarative pattern as AI Desks' SIGNAL_CATALOG/SignalToggle: adding a new
|
||
// knob to CYCLE_STEP_CATALOG (backend) needs zero new frontend code here.
|
||
function CycleStepRow({
|
||
step, value, onChange,
|
||
}: {
|
||
step: CycleStepDef
|
||
value: Record<string, any>
|
||
onChange: (v: Record<string, any>) => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const hasToggle = 'enabled' in step.params
|
||
const enabled = hasToggle ? (value.enabled ?? step.params.enabled.default) : true
|
||
const otherParams = Object.entries(step.params).filter(([k]) => k !== 'enabled')
|
||
const update = (key: string, val: any) => onChange({ ...value, [key]: val })
|
||
|
||
return (
|
||
<div className={clsx('rounded-lg border transition-colors', enabled ? 'border-cyan-700/40 bg-cyan-900/10' : 'border-slate-700/30 bg-dark-800/60')}>
|
||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||
{hasToggle ? (
|
||
<button onClick={() => update('enabled', !enabled)} className="shrink-0 text-xs px-2 py-1 rounded border font-semibold"
|
||
style={{ borderColor: enabled ? '#22d3ee88' : '#47556955', color: enabled ? '#22d3ee' : '#64748b' }}>
|
||
{enabled ? 'ON' : 'OFF'}
|
||
</button>
|
||
) : (
|
||
<span className="w-2 h-2 rounded-full bg-slate-600 shrink-0" />
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className={clsx('text-sm font-medium', enabled ? 'text-white' : 'text-slate-500')}>{step.label}</div>
|
||
<div className="text-xs text-slate-600 truncate">{step.description}</div>
|
||
</div>
|
||
{enabled && otherParams.length > 0 && (
|
||
<button onClick={() => setOpen(o => !o)} className="text-slate-500 hover:text-slate-300 text-xs shrink-0">
|
||
{open ? '▲' : '▼'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{open && enabled && otherParams.length > 0 && (
|
||
<div className="px-4 pb-3 space-y-2 border-t border-slate-700/20 pt-2">
|
||
{otherParams.map(([key, p]) => (
|
||
<div key={key} className="flex items-center gap-3">
|
||
<label className="text-xs text-slate-400 w-40 shrink-0">{p.label ?? key}</label>
|
||
<input
|
||
type="number"
|
||
min={p.min}
|
||
max={p.max}
|
||
step={p.type === 'float' ? 0.01 : 1}
|
||
value={value[key] ?? p.default}
|
||
onChange={e => update(key, p.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value))}
|
||
className="w-28 bg-dark-900 border border-slate-700/40 rounded px-2 py-1 text-xs text-white"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Config() {
|
||
const { data: sources, isLoading } = useSources()
|
||
const { data: config } = useConfig()
|
||
const { data: aiStatus } = useAiStatus()
|
||
const { data: analysisCfg } = useAnalysisConfig()
|
||
const { mutate: updateSources, isPending: savingSources } = useUpdateSources()
|
||
const { mutate: updateApiKeys, isPending: savingKeys } = useUpdateApiKeys()
|
||
const { mutate: saveAnalysis, isPending: savingAnalysis } = useSaveAnalysisConfig()
|
||
const { data: cycleStatus, refetch: refetchCycle } = useCycleStatus()
|
||
const { mutate: updateCycleConfig, isPending: savingCycle } = useUpdateCycleConfig()
|
||
const { mutate: triggerCycle, isPending: triggeringCycle } = useTriggerCycle()
|
||
const { data: exitDefaultsData } = useExitDefaults()
|
||
const { mutate: saveExitDefaults, isPending: savingExitDefaults } = useSaveExitDefaults()
|
||
const { data: optionsGateData } = useOptionsGate()
|
||
const { mutate: saveOptionsGate, isPending: savingOptionsGate } = useSaveOptionsGate()
|
||
|
||
const [localSources, setLocalSources] = useState<Record<string, any> | null>(null)
|
||
const [openaiKey, setOpenaiKey] = useState('')
|
||
const [newsapiKey, setNewsapiKey] = useState('')
|
||
const [eiaKey, setEiaKey] = useState('')
|
||
const [fredKey, setFredKey] = useState('')
|
||
const [fmpKey, setFmpKey] = useState('')
|
||
const [calendarRefreshH, setCalendarRefreshH] = useState(6)
|
||
const [showKeys, setShowKeys] = useState(false)
|
||
const [savedMsg, setSavedMsg] = useState('')
|
||
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'options' | 'profiles' | 'watchlist' | 'data'>('ia')
|
||
|
||
// IV gate local state
|
||
const [ivGateEnabled, setIvGateEnabled] = useState(true)
|
||
const [ivGateHigh, setIvGateHigh] = useState(60)
|
||
const [ivGateExtreme, setIvGateExtreme] = useState(80)
|
||
const [ivGateSkew, setIvGateSkew] = useState(8)
|
||
|
||
// Tech indicators local state
|
||
const { data: techIndicatorsData } = useTechIndicatorsConfig()
|
||
const { mutate: saveTechIndicators, isPending: savingTechIndicators } = useSaveTechIndicatorsConfig()
|
||
const [techEnabled, setTechEnabled] = useState(true)
|
||
const [techAutoCalibrate, setTechAutoCalibrate] = useState(true)
|
||
const [techList, setTechList] = useState<string[]>(['rsi', 'ma', 'bollinger', 'atr'])
|
||
|
||
// Analysis config local state
|
||
const [analysisTopN, setAnalysisTopN] = useState(10)
|
||
const [analysisCategoryDefault, setAnalysisCategoryDefault] = useState('all')
|
||
const [analysisTemplate, setAnalysisTemplate] = useState('')
|
||
|
||
// Exit defaults local state
|
||
const [exitTarget, setExitTarget] = useState(30)
|
||
const [exitStop, setExitStop] = useState(-50)
|
||
const [exitSignalMode, setExitSignalMode] = useState('badge_only')
|
||
const [exitSignalThreshold, setExitSignalThreshold] = useState(25)
|
||
useEffect(() => {
|
||
const ed = exitDefaultsData as any
|
||
if (ed) {
|
||
setExitTarget(ed.target_pct ?? 30)
|
||
setExitStop(ed.stop_loss_pct ?? -50)
|
||
setExitSignalMode(ed.signal_reversal_mode ?? 'badge_only')
|
||
setExitSignalThreshold(ed.signal_reversal_threshold ?? 25)
|
||
}
|
||
}, [exitDefaultsData])
|
||
|
||
// VaR + PnL scheduler state
|
||
const qc = useQueryClient()
|
||
const { data: schedStatus, refetch: refetchSched } = useQuery({
|
||
queryKey: ['sched-status'],
|
||
queryFn: () => fetch(`${API}/api/var/scheduler/status`).then(r => r.json()),
|
||
staleTime: 30_000,
|
||
retry: 1,
|
||
})
|
||
const [varSchedEnabled, setVarSchedEnabled] = useState(false)
|
||
const [varSchedHours, setVarSchedHours] = useState(6)
|
||
const [pnlSchedEnabled, setPnlSchedEnabled] = useState(false)
|
||
const [pnlSchedHours, setPnlSchedHours] = useState(1)
|
||
useEffect(() => {
|
||
if (schedStatus) {
|
||
setVarSchedEnabled(schedStatus.var?.enabled ?? false)
|
||
setVarSchedHours(schedStatus.var?.hours ?? 6)
|
||
setPnlSchedEnabled(schedStatus.pnl?.enabled ?? false)
|
||
setPnlSchedHours(schedStatus.pnl?.hours ?? 1)
|
||
}
|
||
}, [schedStatus])
|
||
const { mutate: saveSchedConfig, isPending: savingSched } = useMutation({
|
||
mutationFn: (cfg: any) => fetch(`${API}/api/var/scheduler/config`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg),
|
||
}).then(r => r.json()),
|
||
onSuccess: () => { refetchSched(); setSavedMsg('Schedulers saved'); setTimeout(() => setSavedMsg(''), 2000) },
|
||
})
|
||
const { mutate: triggerVarNow, isPending: triggeringVar } = useMutation({
|
||
mutationFn: () => fetch(`${API}/api/var/run-now`, { method: 'POST' }).then(r => r.json()),
|
||
onSuccess: () => { setSavedMsg('VaR snapshot saved'); setTimeout(() => setSavedMsg(''), 2000) },
|
||
})
|
||
const { mutate: triggerPnlNow, isPending: triggeringPnl } = useMutation({
|
||
mutationFn: () => fetch(`${API}/api/var/pnl/run-now`, { method: 'POST' }).then(r => r.json()),
|
||
onSuccess: () => { setSavedMsg('PnL snapshot saved'); setTimeout(() => setSavedMsg(''), 2000) },
|
||
})
|
||
|
||
// Auto-cycle local state
|
||
const cs = cycleStatus as any
|
||
const [cycleEnabled, setCycleEnabled] = useState(false)
|
||
const [cycleHours, setCycleHours] = useState(3)
|
||
const [cycleSimilarity, setCycleSimilarity] = useState(0.30)
|
||
const [minEv, setMinEv] = useState(0.0)
|
||
const [minScore, setMinScore] = useState(0)
|
||
const [tradeBudget, setTradeBudget] = useState(5000)
|
||
const [horizonMin, setHorizonMin] = useState(30)
|
||
const [horizonMax, setHorizonMax] = useState(180)
|
||
const [retentionDays, setRetentionDays] = useState(90)
|
||
const [maturityThreshold, setMaturityThreshold] = useState(35)
|
||
const [weekendEnabled, setWeekendEnabled] = useState(true)
|
||
const [weekendTimes, setWeekendTimes] = useState<string[]>(['08:00', '22:00'])
|
||
const [stepConfig, setStepConfig] = useState<Record<string, Record<string, any>>>({})
|
||
const { data: stepCatalog } = useCycleStepCatalog()
|
||
useEffect(() => {
|
||
if (cs) {
|
||
setCycleEnabled(cs.enabled ?? false)
|
||
setCycleHours(cs.interval_hours ?? 3)
|
||
setCycleSimilarity(cs.similarity_threshold ?? 0.30)
|
||
setMinEv(cs.min_ev_threshold ?? 0.0)
|
||
setMinScore(cs.min_score_threshold ?? 0)
|
||
setTradeBudget(cs.trade_budget_eur ?? 5000)
|
||
setHorizonMin(cs.preferred_horizon_min ?? 30)
|
||
setHorizonMax(cs.preferred_horizon_max ?? 180)
|
||
setRetentionDays(cs.journal_retention_days ?? 90)
|
||
setMaturityThreshold(cs.maturity_threshold_pct ?? 35)
|
||
setWeekendEnabled(cs.weekend_cycle_enabled ?? true)
|
||
const times = (cs.weekend_cycle_times || '08:00,22:00').split(',').map((t: string) => t.trim()).filter(Boolean)
|
||
setWeekendTimes(times)
|
||
if (cs.cycle_step_config) setStepConfig(cs.cycle_step_config)
|
||
}
|
||
}, [cs])
|
||
|
||
useEffect(() => {
|
||
if (analysisCfg) {
|
||
setAnalysisTopN(analysisCfg.top_n ?? 10)
|
||
setAnalysisCategoryDefault(analysisCfg.category_filter ?? 'all')
|
||
setAnalysisTemplate(analysisCfg.template ?? '')
|
||
}
|
||
}, [analysisCfg])
|
||
|
||
useEffect(() => {
|
||
if (optionsGateData) {
|
||
setIvGateEnabled(optionsGateData.iv_gate_enabled ?? true)
|
||
setIvGateHigh(optionsGateData.iv_gate_ivr_high ?? 60)
|
||
setIvGateExtreme(optionsGateData.iv_gate_ivr_extreme ?? 80)
|
||
setIvGateSkew(optionsGateData.iv_gate_skew_threshold ?? 8)
|
||
}
|
||
}, [optionsGateData])
|
||
|
||
useEffect(() => {
|
||
if (config?.calendar_refresh_h) {
|
||
const h = parseFloat(config.calendar_refresh_h)
|
||
if (!isNaN(h)) setCalendarRefreshH(h)
|
||
}
|
||
}, [config])
|
||
|
||
useEffect(() => {
|
||
if (techIndicatorsData) {
|
||
setTechEnabled(techIndicatorsData.tech_indicators_enabled ?? true)
|
||
setTechAutoCalibrate(techIndicatorsData.tech_indicators_auto_calibrate ?? true)
|
||
const list = (techIndicatorsData.tech_indicators_list || 'rsi,ma,bollinger,atr').split(',').map((s: string) => s.trim()).filter(Boolean)
|
||
setTechList(list)
|
||
}
|
||
}, [techIndicatorsData])
|
||
|
||
const displaySources = localSources ?? sources ?? {}
|
||
|
||
const toggleSource = (key: string) => {
|
||
setLocalSources(prev => {
|
||
const base = prev ?? sources ?? {}
|
||
return { ...base, [key]: { ...base[key], enabled: !base[key]?.enabled } }
|
||
})
|
||
}
|
||
|
||
const saveSources = () => {
|
||
updateSources(displaySources, {
|
||
onSuccess: () => { setSavedMsg('Sources saved'); setTimeout(() => setSavedMsg(''), 2000) }
|
||
})
|
||
}
|
||
|
||
const saveKeys = () => {
|
||
const keys: Record<string, string> = {}
|
||
if (openaiKey) keys.openai_api_key = openaiKey
|
||
if (newsapiKey) keys.newsapi_key = newsapiKey
|
||
if (eiaKey) keys.eia_api_key = eiaKey
|
||
if (fredKey) keys.fred_api_key = fredKey
|
||
if (fmpKey) keys.fmp_api_key = fmpKey
|
||
updateApiKeys(keys, {
|
||
onSuccess: () => {
|
||
setSavedMsg('API keys saved')
|
||
setTimeout(() => setSavedMsg(''), 2000)
|
||
setOpenaiKey(''); setNewsapiKey(''); setEiaKey(''); setFredKey(''); setFmpKey('')
|
||
}
|
||
})
|
||
}
|
||
|
||
const CONFIG_TABS = [
|
||
{ key: 'ia', label: 'AI & Analysis', icon: Brain },
|
||
{ key: 'cycle', label: 'Auto-Cycle & Logging', icon: Zap },
|
||
{ key: 'sources', label: 'Sources', icon: Globe },
|
||
{ key: 'options', label: 'Options — Settings', icon: TrendingUp },
|
||
{ key: 'profiles', label: 'Risk Profiles', icon: SlidersHorizontal },
|
||
{ key: 'watchlist', label: 'Watchlist', icon: Radar },
|
||
{ key: 'data', label: 'Data Management', icon: DatabaseBackup },
|
||
] as const
|
||
|
||
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">
|
||
<Settings className="w-5 h-5 text-blue-400" /> Configuration
|
||
</h1>
|
||
<p className="text-xs text-slate-500 mt-0.5">API keys, sources, AI & cycle settings</p>
|
||
</div>
|
||
{savedMsg && (
|
||
<div className="flex items-center gap-2 text-emerald-400 text-sm bg-emerald-900/10 border border-emerald-700/30 rounded px-3 py-1.5">
|
||
<CheckCircle className="w-4 h-4" /> {savedMsg}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tab navigation */}
|
||
<div className="flex gap-1 bg-dark-700 p-1 rounded w-fit">
|
||
{CONFIG_TABS.map(({ key, label, icon: Icon }) => (
|
||
<button key={key} onClick={() => setConfigTab(key as any)}
|
||
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': configTab === key,
|
||
'text-slate-400 hover:text-slate-200': configTab !== key,
|
||
})}>
|
||
<Icon className="w-3.5 h-3.5" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── IA & Analyse ── */}
|
||
{configTab === 'ia' && (
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div className="space-y-4">
|
||
{/* AI Status */}
|
||
<div className={clsx('card', aiStatus?.enabled ? 'border-emerald-700/40' : 'border-slate-700/40')}>
|
||
<div className="section-title flex items-center gap-1"><Key className="w-3 h-3" /> AI Status</div>
|
||
<div className="flex items-center gap-3 mb-3">
|
||
{aiStatus?.enabled ? (
|
||
<><CheckCircle className="w-5 h-5 text-emerald-400" />
|
||
<div><div className="text-sm text-emerald-400 font-semibold">OpenAI Connected</div>
|
||
<div className="text-xs text-slate-500">GPT-4o + GPT-4o-mini</div></div></>
|
||
) : (
|
||
<><XCircle className="w-5 h-5 text-red-400" />
|
||
<div><div className="text-sm text-red-400 font-semibold">OpenAI Not configured</div>
|
||
<div className="text-xs text-slate-500">Enter key below</div></div></>
|
||
)}
|
||
</div>
|
||
<div className="text-xs text-slate-600 space-y-1">
|
||
<div>• Speech analysis (Trump, Powell...)</div>
|
||
<div>• AI news classification</div>
|
||
<div>• Pattern evaluation</div>
|
||
<div>• Top 10 contextualized ideas</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* OpenAI API Key */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="section-title mb-0 flex items-center gap-1"><Key className="w-3 h-3" /> OpenAI Key</div>
|
||
<button onClick={() => setShowKeys(!showKeys)} className="text-slate-500 hover:text-slate-300">
|
||
{showKeys ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="text-xs text-slate-500">OpenAI API Key</label>
|
||
<span className={clsx('text-xs', aiStatus?.enabled ? 'text-emerald-400' : 'text-slate-600')}>
|
||
{aiStatus?.enabled ? '✓ Active' : '○ Inactive'}
|
||
</span>
|
||
</div>
|
||
<input
|
||
type={showKeys ? 'text' : 'password'}
|
||
value={openaiKey}
|
||
onChange={e => setOpenaiKey(e.target.value)}
|
||
placeholder="sk-proj-..."
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={saveKeys}
|
||
disabled={savingKeys || !openaiKey}
|
||
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white rounded py-1.5 text-xs font-semibold flex items-center justify-center gap-1.5"
|
||
>
|
||
<Save className="w-3.5 h-3.5" />
|
||
{savingKeys ? 'Saving...' : 'Save key'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: Analysis params */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
|
||
<SlidersHorizontal className="w-4 h-4 text-blue-400" /> AI Analysis Settings
|
||
</h2>
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Default top N results</label>
|
||
<div className="flex gap-1">
|
||
{[5, 10, 15, 20].map(n => (
|
||
<button key={n} onClick={() => setAnalysisTopN(n)}
|
||
className={clsx('flex-1 py-1.5 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': analysisTopN === n,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': analysisTopN !== n,
|
||
})}>
|
||
Top {n}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Default category</label>
|
||
<select value={analysisCategoryDefault} onChange={e => setAnalysisCategoryDefault(e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500">
|
||
<option value="all">All categories</option>
|
||
<option value="energy">Energy</option>
|
||
<option value="metals">Metals</option>
|
||
<option value="agriculture">Agriculture</option>
|
||
<option value="indices">Indices</option>
|
||
<option value="equities">Equities</option>
|
||
<option value="forex">Forex</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="mb-4">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="text-xs text-slate-500">Analysis template (GPT-4o prompt)</label>
|
||
<button onClick={() => setAnalysisTemplate('')} className="text-xs text-slate-600 hover:text-slate-400">
|
||
Default
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
value={analysisTemplate}
|
||
onChange={e => setAnalysisTemplate(e.target.value)}
|
||
rows={8}
|
||
placeholder="Leave empty to use default template..."
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-blue-500 resize-y"
|
||
/>
|
||
<div className="text-xs text-slate-600 mt-1">
|
||
Market context (prices, IV, 1d change) and filtered news are automatically injected.
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => saveAnalysis(
|
||
{ top_n: analysisTopN, category_filter: analysisCategoryDefault, template: analysisTemplate || undefined },
|
||
{ onSuccess: () => { setSavedMsg('Analysis settings saved'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||
)}
|
||
disabled={savingAnalysis}
|
||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingAnalysis ? 'Saving...' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Auto-Cycle & Logging ── */}
|
||
{configTab === 'cycle' && (
|
||
<div className="space-y-5">
|
||
{/* Score min threshold — prominent */}
|
||
<div className="card border-blue-700/40 bg-blue-900/5">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
||
<SlidersHorizontal className="w-4 h-4 text-blue-400" /> Minimum score for the cycle
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Cycle pre-filter — patterns with a score below this are not submitted for analysis.
|
||
Risk profiles then determine which trades get logged.
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Score minimum pre-cycle : <span className="text-blue-300 font-mono font-bold text-lg">{minScore}</span>
|
||
<span className="text-slate-600 ml-1">/100</span>
|
||
</label>
|
||
<input type="range" min="0" max="80" step="5"
|
||
value={minScore}
|
||
onChange={e => setMinScore(parseInt(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>0 (all)</span><span>80 (very selective)</span>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
{minScore === 0
|
||
? 'All patterns processed by the cycle'
|
||
: `Only patterns with score ≥ ${minScore} are submitted to the cycle`}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
EV minimum : <span className="text-blue-300 font-mono font-bold">{minEv >= 0 ? '+' : ''}{(minEv * 100).toFixed(0)}%</span>
|
||
<span className="text-slate-600 ml-1">net attendu</span>
|
||
</label>
|
||
<input type="range" min="-0.5" max="0.5" step="0.05"
|
||
value={minEv}
|
||
onChange={e => setMinEv(parseFloat(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>-50%</span><span>+50%</span>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-2">
|
||
{minEv === 0
|
||
? 'Any net EV accepted'
|
||
: minEv > 0
|
||
? `Net EV ≥ +${(minEv * 100).toFixed(0)}% required`
|
||
: `Filter disabled (EV < ${(minEv * 100).toFixed(0)}%)`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Trade Parameters */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
||
<DollarSign className="w-4 h-4 text-emerald-400" /> Trade Parameters
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Passed into every AI cycle — controls capital sizing and filters out patterns outside your investment horizon.
|
||
</p>
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Simulation budget: <span className="text-emerald-400 font-mono font-bold">€{tradeBudget.toLocaleString()}</span>
|
||
</label>
|
||
<input type="range" min="500" max="50000" step="500"
|
||
value={tradeBudget}
|
||
onChange={e => setTradeBudget(parseInt(e.target.value))}
|
||
className="w-full accent-emerald-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>€500</span><span>€50,000</span>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">
|
||
Max per position: <span className="text-slate-300 font-mono">€{Math.round(tradeBudget * 0.25).toLocaleString()}</span> (25%)
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Min horizon: <span className="text-blue-400 font-mono font-bold">{horizonMin}d</span>
|
||
</label>
|
||
<input type="range" min="1" max="90" step="1"
|
||
value={horizonMin}
|
||
onChange={e => setHorizonMin(Math.min(parseInt(e.target.value), horizonMax - 5))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>1d</span><span>90d</span>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">Patterns shorter than {horizonMin}d penalized in R/R</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Max horizon: <span className="text-blue-400 font-mono font-bold">{horizonMax}d</span>
|
||
</label>
|
||
<input type="range" min="30" max="365" step="5"
|
||
value={horizonMax}
|
||
onChange={e => setHorizonMax(Math.max(parseInt(e.target.value), horizonMin + 5))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>30d</span><span>365d</span>
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-1">Patterns longer than {horizonMax}d penalized in R/R</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 p-3 rounded bg-emerald-900/10 border border-emerald-700/20 text-xs text-emerald-300">
|
||
Active mandate: budget <span className="font-mono">€{tradeBudget.toLocaleString()}</span> · horizon{' '}
|
||
<span className="font-mono">{horizonMin}–{horizonMax}d</span> · AI will size + filter suggestions accordingly
|
||
</div>
|
||
</div>
|
||
|
||
{/* Auto-Cycle settings */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||
<Zap className="w-4 h-4 text-blue-400" /> Auto-Cycle Intelligence
|
||
</h2>
|
||
{cs && (
|
||
<div className="flex items-center gap-2 text-xs">
|
||
{cs.running ? (
|
||
<span className="flex items-center gap-1 text-blue-400 bg-blue-900/30 border border-blue-700/30 px-2 py-0.5 rounded animate-pulse">
|
||
<RefreshCw className="w-3 h-3 animate-spin" /> Running...
|
||
</span>
|
||
) : cs.scheduler_alive ? (
|
||
<span className="text-emerald-400 bg-emerald-900/30 border border-emerald-700/30 px-2 py-0.5 rounded">
|
||
● Scheduler active
|
||
</span>
|
||
) : (
|
||
<span className="text-slate-600 bg-dark-700 border border-slate-700/30 px-2 py-0.5 rounded">
|
||
○ Scheduler inactive
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Every N hours: suggests new patterns, deduplicates, scores everything, logs prices, and generates an AI comment.
|
||
</p>
|
||
|
||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">Enable auto-cycle</label>
|
||
<button
|
||
onClick={() => setCycleEnabled(!cycleEnabled)}
|
||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||
'bg-blue-600 border-blue-500 text-white': cycleEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-400 hover:border-slate-500': !cycleEnabled,
|
||
})}>
|
||
{cycleEnabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">Interval (hours)</label>
|
||
<div className="flex gap-1">
|
||
{[1, 3, 6, 12].map(h => (
|
||
<button key={h} onClick={() => setCycleHours(h)}
|
||
className={clsx('flex-1 py-2 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': cycleHours === h,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': cycleHours !== h,
|
||
})}>
|
||
{h}h
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Max similarity ({Math.round(cycleSimilarity * 100)}%)
|
||
</label>
|
||
<input type="range" min="0.1" max="0.8" step="0.05"
|
||
value={cycleSimilarity}
|
||
onChange={e => setCycleSimilarity(parseFloat(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>10% (strict)</span><span>80% (permissif)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Journal retention ({retentionDays}d)
|
||
</label>
|
||
<div className="flex gap-1">
|
||
{[30, 60, 90, 180].map(d => (
|
||
<button key={d} onClick={() => setRetentionDays(d)}
|
||
className={clsx('flex-1 py-2 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': retentionDays === d,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': retentionDays !== d,
|
||
})}>
|
||
{d}j
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Maturity threshold ({maturityThreshold}%)
|
||
</label>
|
||
<div className="flex gap-1">
|
||
{[20, 30, 35, 50].map(p => (
|
||
<button key={p} onClick={() => setMaturityThreshold(p)}
|
||
className={clsx('flex-1 py-2 rounded text-sm transition-colors', {
|
||
'bg-blue-600 text-white': maturityThreshold === p,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': maturityThreshold !== p,
|
||
})}>
|
||
{p}%
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weekend scheduling */}
|
||
<div className="border border-slate-700/50 rounded-lg p-3 mb-4 bg-dark-700/30">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div>
|
||
<span className="text-xs font-semibold text-slate-300">Weekend cycles</span>
|
||
<p className="text-[10px] text-slate-500 mt-0.5">Sat/Sun — Globex opens Sun ~18h UTC. Times in UTC.</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setWeekendEnabled(!weekendEnabled)}
|
||
className={clsx('px-3 py-1.5 rounded border text-xs font-semibold transition-all', {
|
||
'bg-amber-600/20 border-amber-500/50 text-amber-300': weekendEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-500 hover:border-slate-500': !weekendEnabled,
|
||
})}>
|
||
{weekendEnabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
</div>
|
||
{weekendEnabled && (
|
||
<div>
|
||
<label className="text-[10px] text-slate-500 mb-1.5 block">UTC times (click to enable/disable)</label>
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{['06:00', '08:00', '12:00', '18:00', '22:00', '00:00'].map(t => {
|
||
const active = weekendTimes.includes(t)
|
||
return (
|
||
<button key={t}
|
||
onClick={() => setWeekendTimes(prev =>
|
||
active ? prev.filter(x => x !== t) : [...prev, t].sort()
|
||
)}
|
||
className={clsx('px-2.5 py-1 rounded text-xs font-mono transition-colors', {
|
||
'bg-amber-600 text-white': active,
|
||
'bg-dark-700 text-slate-500 hover:text-slate-300 border border-slate-700': !active,
|
||
})}>
|
||
{t}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
{weekendTimes.length === 0 && (
|
||
<p className="text-[10px] text-red-400 mt-1">Select at least one time</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{cs?.last_cycle && (
|
||
<div className="card bg-dark-700/50 mb-2 text-xs space-y-2">
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<span className="text-slate-500">Last cycle:</span>
|
||
<span className="text-slate-300">{cs.last_cycle.started_at?.slice(0, 16)} UTC</span>
|
||
<span className={clsx('badge', cs.last_cycle.status === 'completed' ? 'badge-green' : 'badge-red')}>
|
||
{cs.last_cycle.status}
|
||
</span>
|
||
<span className="text-slate-500">+{cs.last_cycle.patterns_added} patterns</span>
|
||
<span className="text-slate-500">{cs.last_cycle.patterns_scored} scored</span>
|
||
<span className="text-slate-500">Geo: {cs.last_cycle.geo_score}</span>
|
||
<span className="text-slate-500 capitalize">{cs.last_cycle.dominant_regime}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{cs?.enabled && cs?.next_run_at && !cs?.running && (
|
||
<div className="card bg-dark-700/50 mb-4 text-xs">
|
||
<NextRunCountdown nextRunAt={cs.next_run_at} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => updateCycleConfig(
|
||
{
|
||
enabled: cycleEnabled,
|
||
interval_hours: cycleHours,
|
||
similarity_threshold: cycleSimilarity,
|
||
min_ev_threshold: minEv,
|
||
min_score_threshold: minScore,
|
||
trade_budget_eur: tradeBudget,
|
||
preferred_horizon_min: horizonMin,
|
||
preferred_horizon_max: horizonMax,
|
||
journal_retention_days: retentionDays,
|
||
maturity_threshold_pct: maturityThreshold,
|
||
weekend_cycle_enabled: weekendEnabled,
|
||
weekend_cycle_times: weekendTimes.length > 0 ? weekendTimes.join(',') : '08:00,22:00',
|
||
cycle_step_config: stepConfig,
|
||
},
|
||
{ onSuccess: () => { refetchCycle(); setSavedMsg('Auto-cycle configured'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||
)}
|
||
disabled={savingCycle}
|
||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingCycle ? 'Saving...' : 'Apply'}
|
||
</button>
|
||
<button
|
||
onClick={() => triggerCycle(undefined, { onSuccess: () => { refetchCycle(); setSavedMsg('Cycle started!'); setTimeout(() => setSavedMsg(''), 3000) } })}
|
||
disabled={triggeringCycle || !aiStatus?.enabled}
|
||
className="flex items-center gap-1.5 border border-blue-500/50 text-blue-400 hover:bg-blue-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||
<RefreshCw className={clsx('w-4 h-4', triggeringCycle && 'animate-spin')} />
|
||
Run now
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Étapes du cycle ── */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
||
<SlidersHorizontal className="w-4 h-4 text-cyan-400" /> Étapes du cycle
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Chaque étape du cycle automatique (y compris en mode planifié) lit ces réglages —
|
||
activer/désactiver ou ajuster une fenêtre ici change réellement ce qui se passe au prochain cycle.
|
||
</p>
|
||
{['portfolio', 'ai', 'data'].map(group => {
|
||
const groupSteps = (stepCatalog ?? []).filter(s => s.group === group)
|
||
if (groupSteps.length === 0) return null
|
||
return (
|
||
<div key={group} className="mb-4 last:mb-0">
|
||
<div className="text-[10px] uppercase tracking-wide text-slate-600 mb-2">
|
||
{group === 'portfolio' ? 'Portefeuille' : group === 'ai' ? 'Analyse IA' : 'Données & fenêtres'}
|
||
</div>
|
||
<div className="space-y-2">
|
||
{groupSteps.map(step => (
|
||
<CycleStepRow
|
||
key={step.id}
|
||
step={step}
|
||
value={stepConfig[step.id] ?? {}}
|
||
onChange={v => setStepConfig(prev => ({ ...prev, [step.id]: v }))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
<div className="mt-2 text-[10px] text-slate-600">
|
||
Ces réglages sont appliqués via le bouton "Apply" ci-dessus (Auto-Cycle Intelligence).
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── VaR & PnL Schedulers ── */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
|
||
<Gauge className="w-4 h-4 text-red-400" /> Schedulers VaR & PnL
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Automatically computes and saves VaR (Black-Scholes delta) and PnL snapshots at defined intervals.
|
||
Data is accessible from the VaR Analysis page with full history.
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||
{/* VaR scheduler */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Gauge className="w-3.5 h-3.5 text-red-400" />
|
||
<span className="text-sm font-semibold text-slate-300">Scheduler VaR</span>
|
||
{schedStatus?.var?.alive
|
||
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto">● Active</span>
|
||
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto">○ Inactive</span>}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Enable</label>
|
||
<button onClick={() => setVarSchedEnabled(!varSchedEnabled)}
|
||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||
'bg-red-700 border-red-600 text-white': varSchedEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-400': !varSchedEnabled,
|
||
})}>
|
||
{varSchedEnabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Interval</label>
|
||
<div className="flex gap-1">
|
||
{[1, 3, 6, 12, 24].map(h => (
|
||
<button key={h} onClick={() => setVarSchedHours(h)}
|
||
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
|
||
'bg-red-700 text-white': varSchedHours === h,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': varSchedHours !== h,
|
||
})}>
|
||
{h}h
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* PnL scheduler */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<DollarSign className="w-3.5 h-3.5 text-emerald-400" />
|
||
<span className="text-sm font-semibold text-slate-300">Scheduler PnL</span>
|
||
{schedStatus?.pnl?.alive
|
||
? <span className="text-[10px] bg-emerald-900/30 text-emerald-400 border border-emerald-700/30 px-1.5 py-0.5 rounded ml-auto">● Active</span>
|
||
: <span className="text-[10px] bg-dark-700 text-slate-600 border border-slate-700/30 px-1.5 py-0.5 rounded ml-auto">○ Inactive</span>}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Enable</label>
|
||
<button onClick={() => setPnlSchedEnabled(!pnlSchedEnabled)}
|
||
className={clsx('w-full py-2 rounded border text-sm font-semibold transition-all', {
|
||
'bg-emerald-700 border-emerald-600 text-white': pnlSchedEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-400': !pnlSchedEnabled,
|
||
})}>
|
||
{pnlSchedEnabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-1 block">Interval</label>
|
||
<div className="flex gap-1">
|
||
{[1, 2, 4, 8, 24].map(h => (
|
||
<button key={h} onClick={() => setPnlSchedHours(h)}
|
||
className={clsx('flex-1 py-1.5 rounded text-xs transition-colors', {
|
||
'bg-emerald-700 text-white': pnlSchedHours === h,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': pnlSchedHours !== h,
|
||
})}>
|
||
{h}h
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<button
|
||
onClick={() => saveSchedConfig({ var_enabled: varSchedEnabled, var_hours: varSchedHours, pnl_enabled: pnlSchedEnabled, pnl_hours: pnlSchedHours })}
|
||
disabled={savingSched}
|
||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingSched ? 'Saving…' : 'Apply'}
|
||
</button>
|
||
<button
|
||
onClick={() => triggerVarNow()}
|
||
disabled={triggeringVar}
|
||
className="flex items-center gap-1.5 border border-red-500/50 text-red-400 hover:bg-red-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||
<Gauge className={clsx('w-4 h-4', triggeringVar && 'animate-spin')} />
|
||
VaR snapshot now
|
||
</button>
|
||
<button
|
||
onClick={() => triggerPnlNow()}
|
||
disabled={triggeringPnl}
|
||
className="flex items-center gap-1.5 border border-emerald-500/50 text-emerald-400 hover:bg-emerald-900/20 disabled:opacity-40 px-4 py-2 rounded text-sm font-semibold">
|
||
<DollarSign className={clsx('w-4 h-4', triggeringPnl && 'animate-spin')} />
|
||
PnL snapshot now
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Calendar auto-sync frequency */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-3">
|
||
<RefreshCw className="w-4 h-4 text-indigo-400" /> Calendar Auto-Sync
|
||
</h2>
|
||
<div className="text-xs text-slate-400 mb-3">
|
||
Interval between automatic economic calendar syncs (Forex Factory live JSON + HTML scraper; FMP API used as fallback for weeks 3+ if the FF scraper gets blocked — needs an FMP key below).
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<label className="text-xs text-slate-500 whitespace-nowrap">Refresh every</label>
|
||
<div className="flex gap-1">
|
||
{[2, 4, 6, 12, 24].map(h => (
|
||
<button key={h} onClick={() => setCalendarRefreshH(h)}
|
||
className={clsx('px-3 py-1.5 rounded text-xs transition-colors', {
|
||
'bg-indigo-700 text-white': calendarRefreshH === h,
|
||
'bg-dark-700 text-slate-400 hover:text-slate-200': calendarRefreshH !== h,
|
||
})}>
|
||
{h}h
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => updateApiKeys({ calendar_refresh_h: String(calendarRefreshH) }, {
|
||
onSuccess: () => { setSavedMsg('Calendar refresh saved'); setTimeout(() => setSavedMsg(''), 2000) }
|
||
})}
|
||
disabled={savingKeys}
|
||
className="flex items-center gap-1.5 bg-indigo-700 hover:bg-indigo-600 disabled:opacity-40 text-white px-3 py-1.5 rounded text-xs font-semibold">
|
||
<Save className="w-3.5 h-3.5" />
|
||
{savingKeys ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Sources ── */}
|
||
{configTab === 'sources' && (
|
||
<div className="space-y-5">
|
||
{/* Data API Keys */}
|
||
<div className="card">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<div className="section-title mb-0 flex items-center gap-1"><Key className="w-3 h-3" /> Data API Keys</div>
|
||
<button onClick={() => setShowKeys(!showKeys)} className="text-slate-500 hover:text-slate-300">
|
||
{showKeys ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-4">
|
||
{[
|
||
{ label: 'NewsAPI Key', value: newsapiKey, setter: setNewsapiKey, placeholder: 'Get at newsapi.org', configKey: 'newsapi_key' },
|
||
{ label: 'EIA API Key', value: eiaKey, setter: setEiaKey, placeholder: 'Get at eia.gov', configKey: 'eia_api_key' },
|
||
{ label: 'FRED API Key', value: fredKey, setter: setFredKey, placeholder: 'Get at fred.stlouisfed.org', configKey: 'fred_api_key' },
|
||
{ label: 'FMP API Key', value: fmpKey, setter: setFmpKey, placeholder: 'Calendar fallback — financialmodelingprep.com', configKey: 'fmp_api_key' },
|
||
].map(({ label, value, setter, placeholder, configKey }) => (
|
||
<div key={label}>
|
||
<label className="text-xs text-slate-500 mb-1 flex items-center justify-between">
|
||
<span>{label}</span>
|
||
{config?.[`${configKey}_set`] ? (
|
||
<span className="text-emerald-400 text-[10px] font-semibold">✓ active</span>
|
||
) : (
|
||
<span className="text-slate-600 text-[10px]">not set</span>
|
||
)}
|
||
</label>
|
||
<input
|
||
type={showKeys ? 'text' : 'password'}
|
||
value={value}
|
||
onChange={e => setter(e.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-blue-500 font-mono"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={saveKeys}
|
||
disabled={savingKeys || (!newsapiKey && !eiaKey && !fredKey && !fmpKey)}
|
||
className="mt-3 flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingKeys ? 'Saving...' : 'Save keys'}
|
||
</button>
|
||
</div>
|
||
|
||
<SaxoConnectionCard />
|
||
|
||
{/* RSS / data sources */}
|
||
{isLoading ? (
|
||
<div className="card animate-pulse h-40 bg-dark-700" />
|
||
) : (
|
||
<>
|
||
{Object.entries(SOURCE_CATEGORIES).map(([catName, keys]) => (
|
||
<div key={catName} className="card">
|
||
<div className="section-title flex items-center gap-1">
|
||
<Globe className="w-3 h-3" /> {catName}
|
||
</div>
|
||
<div className="space-y-2">
|
||
{keys.map(key => {
|
||
const src = displaySources[key] ?? {}
|
||
const enabled = src.enabled ?? false
|
||
const requiresKey = src.requires_key
|
||
const doc = SOURCE_DOCS[key]
|
||
const keySet = requiresKey ? !!config?.[requiresKey + '_set'] : true
|
||
return (
|
||
<div key={key} className={clsx('flex items-start justify-between p-3 rounded border transition-all',
|
||
enabled ? 'border-blue-500/40 bg-blue-900/5' : 'border-slate-700/30 bg-dark-700/30')}>
|
||
<div className="flex-1 min-w-0 mr-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-white font-semibold">{src.name || key}</span>
|
||
<span className={clsx('badge text-xs', {
|
||
'badge-green': doc?.cost === 'Free' || doc?.cost === 'Totally free',
|
||
'badge-blue': doc?.cost?.includes('free') || doc?.cost?.includes('registration'),
|
||
'badge-yellow': doc?.cost?.includes('key') || doc?.cost?.includes('key'),
|
||
'badge-red': doc?.cost?.includes('Paid'),
|
||
})}>
|
||
{doc?.cost}
|
||
</span>
|
||
{requiresKey && !keySet && <span className="badge badge-orange text-xs">Key required</span>}
|
||
</div>
|
||
<div className="text-xs text-slate-500 mt-0.5">{doc?.description}</div>
|
||
</div>
|
||
<button onClick={() => toggleSource(key)} disabled={requiresKey && !keySet}
|
||
className={clsx('shrink-0 w-10 h-5 rounded-full border transition-all relative',
|
||
enabled ? 'bg-blue-600 border-blue-500' : 'bg-dark-600 border-slate-600',
|
||
requiresKey && !keySet && 'opacity-40 cursor-not-allowed')}>
|
||
<div className={clsx('absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all',
|
||
enabled ? 'left-5' : 'left-0.5')} />
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="flex justify-end">
|
||
<button onClick={saveSources} disabled={savingSources || !localSources}
|
||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingSources ? 'Saving...' : 'Apply changes'}
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Options — Paramètres ── */}
|
||
{configTab === 'options' && (
|
||
<div className="space-y-5">
|
||
|
||
{/* ── IV Gate — Paramètres d'entrée ── */}
|
||
<div className="card border-blue-700/30 bg-blue-900/5">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||
<ShieldAlert className="w-4 h-4 text-blue-400" /> IV Gate — Entry Filters
|
||
</h2>
|
||
<button
|
||
onClick={() => setIvGateEnabled(!ivGateEnabled)}
|
||
className={clsx('text-xs px-3 py-1 rounded border font-semibold transition-all', {
|
||
'bg-blue-600 border-blue-500 text-white': ivGateEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-400': !ivGateEnabled,
|
||
})}>
|
||
{ivGateEnabled ? '✓ Gate active' : '○ Gate disabled'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-slate-500 mb-5">
|
||
Automatically blocks trades with verdict <span className="text-red-400 font-semibold">ALERT</span> before
|
||
they are logged in the journal. An ALERT trade is a strategy incompatible with the current volatility regime
|
||
(e.g. Long Call at IVR 100%). Blocked trades appear in skipped trades with reason <span className="text-slate-400 font-mono">[IV_GATE]</span>.
|
||
</p>
|
||
|
||
<div className={clsx('space-y-5 transition-opacity', !ivGateEnabled && 'opacity-40 pointer-events-none')}>
|
||
{/* IVR High — seuil vol chère */}
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-xs text-slate-400 font-medium">
|
||
High vol threshold (IVR High)
|
||
</label>
|
||
<span className={clsx('font-mono font-bold text-base', ivGateHigh >= 70 ? 'text-orange-400' : 'text-yellow-400')}>
|
||
{ivGateHigh}%
|
||
</span>
|
||
</div>
|
||
<input type="range" min="30" max="85" step="5"
|
||
value={ivGateHigh}
|
||
onChange={e => {
|
||
const v = parseInt(e.target.value)
|
||
setIvGateHigh(v)
|
||
if (ivGateExtreme <= v) setIvGateExtreme(Math.min(v + 10, 100))
|
||
}}
|
||
className="w-full accent-yellow-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>30% (strict)</span><span>85% (permissif)</span>
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1.5 space-y-0.5">
|
||
<div>IVR < {ivGateHigh}% → <span className="text-emerald-400">Long Call / Long Put OK</span></div>
|
||
<div>IVR {ivGateHigh}–{ivGateExtreme}% → <span className="text-yellow-400">Spreads requis</span> — naked long → WARN</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-xs text-slate-400 font-medium">
|
||
Extreme vol threshold (IVR Extreme)
|
||
</label>
|
||
<span className="font-mono font-bold text-base text-red-400">{ivGateExtreme}%</span>
|
||
</div>
|
||
<input type="range" min="50" max="100" step="5"
|
||
value={ivGateExtreme}
|
||
onChange={e => {
|
||
const v = parseInt(e.target.value)
|
||
setIvGateExtreme(v)
|
||
if (ivGateHigh >= v) setIvGateHigh(Math.max(v - 10, 30))
|
||
}}
|
||
className="w-full accent-red-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>50%</span><span>100%</span>
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1.5 space-y-0.5">
|
||
<div>IVR > {ivGateExtreme}% → <span className="text-red-400 font-semibold">BLOCKED</span> — naked long vol → ALERT</div>
|
||
<div>Straddle / Strangle → ALERT when IVR > {ivGateExtreme}% (penalty ×1.3)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Skew threshold */}
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<label className="text-xs text-slate-400 font-medium">
|
||
Put skew threshold (expensive protection)
|
||
</label>
|
||
<span className="font-mono font-bold text-base text-orange-400">{ivGateSkew} pts</span>
|
||
</div>
|
||
<input type="range" min="2" max="20" step="1"
|
||
value={ivGateSkew}
|
||
onChange={e => setIvGateSkew(parseInt(e.target.value))}
|
||
className="w-full accent-orange-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>2pts (very sensitive)</span><span>20pts (very tolerant)</span>
|
||
</div>
|
||
<div className="text-[10px] text-slate-500 mt-1.5">
|
||
Put skew > {ivGateSkew}pts → puts very expensive, WARN signal on Long Put
|
||
</div>
|
||
</div>
|
||
|
||
{/* Visual summary */}
|
||
<div className="bg-dark-700/60 rounded-lg px-4 py-3 text-xs space-y-2">
|
||
<div className="text-slate-500 font-semibold mb-2 text-[10px] uppercase tracking-wide">Current gate summary</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
||
<span className="text-slate-400">IVR < <span className="text-emerald-400 font-mono">{ivGateHigh}%</span> → Any strategy OK</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||
<span className="text-slate-400">IVR <span className="text-yellow-400 font-mono">{ivGateHigh}–{ivGateExtreme}%</span> → Spreads preferred</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||
<span className="text-slate-400">IVR > <span className="text-red-400 font-mono">{ivGateExtreme}%</span> → Long naked → <strong className="text-red-400">BLOCKED</strong></span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||
<span className="text-slate-400">Skew > <span className="text-orange-400 font-mono">{ivGateSkew}pts</span> → Expensive puts (WARN)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-5 pt-4 border-t border-slate-700/30">
|
||
<button
|
||
onClick={() => saveOptionsGate(
|
||
{ iv_gate_enabled: ivGateEnabled, iv_gate_ivr_high: ivGateHigh,
|
||
iv_gate_ivr_extreme: ivGateExtreme, iv_gate_skew_threshold: ivGateSkew },
|
||
{ onSuccess: () => { setSavedMsg('IV Gate saved'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||
)}
|
||
disabled={savingOptionsGate}
|
||
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingOptionsGate ? 'Saving...' : 'Save gate'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Paramètres de sortie ── */}
|
||
<div className="card">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
||
<Lock className="w-4 h-4 text-amber-400" /> Trade Exit Settings
|
||
</h2>
|
||
<p className="text-xs text-slate-500 mb-5">
|
||
Default values for targets and stop-losses. Each trade can have its own thresholds (Journal → Open).
|
||
</p>
|
||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Profit target: <span className="text-emerald-400 font-mono font-bold">+{exitTarget}%</span>
|
||
<span className="text-slate-600 ml-1">(underlying P&L)</span>
|
||
</label>
|
||
<input type="range" min="5" max="150" step="5"
|
||
value={exitTarget}
|
||
onChange={e => setExitTarget(parseFloat(e.target.value))}
|
||
className="w-full accent-emerald-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>+5%</span><span>+150%</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Stop-loss: <span className="text-red-400 font-mono font-bold">{exitStop}%</span>
|
||
<span className="text-slate-600 ml-1">(underlying P&L)</span>
|
||
</label>
|
||
<input type="range" min="-90" max="-5" step="5"
|
||
value={exitStop}
|
||
onChange={e => setExitStop(parseFloat(e.target.value))}
|
||
className="w-full accent-red-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>-90%</span><span>-5%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-6 mb-5">
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">AI reversal signal mode</label>
|
||
<div className="flex gap-2">
|
||
{[
|
||
{ value: 'badge_only', label: '🔔 Badge only' },
|
||
{ value: 'auto', label: '⚡ Auto (future)' },
|
||
].map(opt => (
|
||
<button key={opt.value} onClick={() => setExitSignalMode(opt.value)}
|
||
className={clsx('flex-1 py-2 rounded text-xs border transition-all', {
|
||
'bg-blue-600 border-blue-500 text-white': exitSignalMode === opt.value,
|
||
'bg-dark-700 border-slate-700 text-slate-400 hover:text-slate-200': exitSignalMode !== opt.value,
|
||
})}>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="text-[10px] text-slate-600 mt-1">
|
||
Badge only: visible alert, you confirm manually. Auto: closure suggested.
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 mb-2 block">
|
||
Signal reversal threshold: <span className="text-blue-400 font-mono font-bold">{exitSignalThreshold}pts</span>
|
||
</label>
|
||
<input type="range" min="10" max="60" step="5"
|
||
value={exitSignalThreshold}
|
||
onChange={e => setExitSignalThreshold(parseFloat(e.target.value))}
|
||
className="w-full accent-blue-500" />
|
||
<div className="flex justify-between text-[10px] text-slate-600 mt-1">
|
||
<span>10pts (sensitive)</span><span>60pts (tolerant)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={() => saveExitDefaults(
|
||
{ target_pct: exitTarget, stop_loss_pct: exitStop,
|
||
signal_reversal_mode: exitSignalMode, signal_reversal_threshold: exitSignalThreshold },
|
||
{ onSuccess: () => { setSavedMsg('Exit settings saved'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||
)}
|
||
disabled={savingExitDefaults}
|
||
className="flex items-center gap-1.5 bg-amber-600 hover:bg-amber-500 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingExitDefaults ? 'Saving...' : 'Save thresholds'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Indicateurs techniques du sous-jacent ── */}
|
||
<div className="card border-purple-700/30 bg-purple-900/5">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||
<TrendingUp className="w-4 h-4 text-purple-400" /> Underlying Technical Indicators
|
||
</h2>
|
||
<button
|
||
onClick={() => setTechEnabled(!techEnabled)}
|
||
className={clsx('text-xs px-3 py-1 rounded border font-semibold transition-all', {
|
||
'bg-purple-600 border-purple-500 text-white': techEnabled,
|
||
'bg-dark-700 border-slate-700 text-slate-400': !techEnabled,
|
||
})}>
|
||
{techEnabled ? '✓ Enabled' : '○ Disabled'}
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-slate-500 mb-4">
|
||
Injects RSI, MA, Bollinger and ATR into the AI context at each cycle. Periods are automatically calibrated based on the option's horizon.
|
||
</p>
|
||
|
||
<div className={clsx('space-y-4 transition-opacity', !techEnabled && 'opacity-40 pointer-events-none')}>
|
||
{/* Indicateurs à activer */}
|
||
<div>
|
||
<label className="text-xs text-slate-400 font-medium block mb-2">Active indicators</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
{[
|
||
{ key: 'rsi', label: 'RSI', desc: 'Momentum / overbought-oversold' },
|
||
{ key: 'ma', label: 'MA fast/slow', desc: 'Trend & golden/death cross' },
|
||
{ key: 'bollinger', label: 'Bollinger', desc: 'Position in vol bands' },
|
||
{ key: 'atr', label: 'ATR', desc: 'Recent realized volatility' },
|
||
].map(({ key, label, desc }) => {
|
||
const active = techList.includes(key)
|
||
return (
|
||
<button
|
||
key={key}
|
||
title={desc}
|
||
onClick={() => setTechList(prev =>
|
||
active ? prev.filter(k => k !== key) : [...prev, key]
|
||
)}
|
||
className={clsx('text-xs px-3 py-1.5 rounded border font-semibold transition-all', {
|
||
'bg-purple-700 border-purple-500 text-white': active,
|
||
'bg-dark-700 border-slate-700 text-slate-400 hover:border-purple-600': !active,
|
||
})}>
|
||
{active ? '✓ ' : ''}{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
<p className="text-[10px] text-slate-600 mt-1.5">Hover to see each indicator's description.</p>
|
||
</div>
|
||
|
||
{/* Calibration auto */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-slate-300 font-medium">Auto-calibration by horizon</p>
|
||
<p className="text-[10px] text-slate-500">≤30j → RSI14, MA20/50 | ≤90j → RSI21, MA50/100 | >90j → RSI28, MA100/200</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setTechAutoCalibrate(!techAutoCalibrate)}
|
||
className={clsx('text-xs px-3 py-1 rounded border font-semibold transition-all flex-shrink-0 ml-4', {
|
||
'bg-purple-600 border-purple-500 text-white': techAutoCalibrate,
|
||
'bg-dark-700 border-slate-700 text-slate-400': !techAutoCalibrate,
|
||
})}>
|
||
{techAutoCalibrate ? '✓ Auto' : '○ Fixed'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => saveTechIndicators(
|
||
{ tech_indicators_enabled: techEnabled, tech_indicators_list: techList.join(','), tech_indicators_auto_calibrate: techAutoCalibrate },
|
||
{ onSuccess: () => { setSavedMsg('Technical indicators saved'); setTimeout(() => setSavedMsg(''), 2000) } }
|
||
)}
|
||
disabled={savingTechIndicators}
|
||
className="mt-4 flex items-center gap-1.5 bg-purple-700 hover:bg-purple-600 disabled:opacity-40 text-white px-4 py-2 rounded text-sm font-semibold">
|
||
<Save className="w-4 h-4" />
|
||
{savingTechIndicators ? 'Saving...' : 'Save indicators'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Profils de risque ── */}
|
||
{configTab === 'profiles' && <RiskProfilesCard />}
|
||
|
||
{/* ── Watchlist ── */}
|
||
{configTab === 'watchlist' && <WatchlistCard />}
|
||
|
||
{/* ── Data Management ── */}
|
||
{configTab === 'data' && <DataManagementCard />}
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Data Management card ───────────────────────────────────────────────────────
|
||
|
||
function PurgeButton({ label, description, tables, endpoint, color = 'red' }: {
|
||
label: string
|
||
description: string
|
||
tables: string[]
|
||
endpoint: string
|
||
color?: 'red' | 'orange'
|
||
}) {
|
||
const qc = useQueryClient()
|
||
const [confirm, setConfirm] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
const [done, setDone] = useState<string | null>(null)
|
||
|
||
const handlePurge = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await fetch(endpoint, { method: 'DELETE' })
|
||
const data = await res.json()
|
||
setDone(`Deleted ${data.deleted ?? '?'} rows`)
|
||
setConfirm(false)
|
||
qc.invalidateQueries()
|
||
} catch (e: any) {
|
||
setDone(`Error: ${e?.message}`)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const borderCol = color === 'red' ? 'border-red-800/40' : 'border-orange-800/40'
|
||
const bgCol = color === 'red' ? 'bg-red-900/5' : 'bg-orange-900/5'
|
||
const btnCol = color === 'red'
|
||
? 'bg-red-700 hover:bg-red-600 disabled:opacity-50'
|
||
: 'bg-orange-700 hover:bg-orange-600 disabled:opacity-50'
|
||
|
||
return (
|
||
<div className={`border ${borderCol} ${bgCol} rounded-lg p-4`}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="text-sm font-semibold text-slate-200 mb-0.5">{label}</div>
|
||
<div className="text-xs text-slate-500 mb-1.5">{description}</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{tables.map(t => (
|
||
<span key={t} className="text-[10px] font-mono bg-slate-800 text-slate-400 px-1.5 py-0.5 rounded">{t}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex-shrink-0 flex flex-col items-end gap-1.5">
|
||
{done && <span className="text-[10px] text-emerald-400">{done}</span>}
|
||
{!confirm ? (
|
||
<button onClick={() => { setConfirm(true); setDone(null) }}
|
||
className={`text-xs px-3 py-1.5 rounded text-white font-semibold ${btnCol}`}>
|
||
<Trash2 className="w-3 h-3 inline mr-1" />Purge
|
||
</button>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs text-red-400 font-semibold">Are you sure?</span>
|
||
<button onClick={handlePurge} disabled={loading}
|
||
className={`text-xs px-3 py-1.5 rounded text-white font-semibold ${btnCol}`}>
|
||
{loading ? 'Deleting…' : 'Yes, delete all'}
|
||
</button>
|
||
<button onClick={() => setConfirm(false)} className="text-xs text-slate-500 hover:text-slate-300">Cancel</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function DataManagementCard() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<DatabaseBackup className="w-4 h-4 text-red-400" />
|
||
<h2 className="text-base font-bold text-white">Data Management</h2>
|
||
<span className="text-xs text-slate-500 ml-2">Irreversible operations — all deletes are permanent</span>
|
||
</div>
|
||
|
||
<PurgeButton
|
||
label="System Logs"
|
||
description="Clear all system logs immediately (normally kept 30 days)."
|
||
tables={['system_logs']}
|
||
endpoint="/api/logs/purge-all"
|
||
color="orange"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="AI Reports (Rapport IA)"
|
||
description="Delete all generated cycle reports and trade assessments."
|
||
tables={['cycle_reports', 'ai_reports']}
|
||
endpoint="/api/reports/purge-all"
|
||
color="orange"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="Portfolio — All Positions"
|
||
description="Delete every position (open + closed) and all trade entry price history. Cannot be undone."
|
||
tables={['portfolio', 'trade_entry_prices']}
|
||
endpoint="/api/portfolio/purge-all"
|
||
color="red"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="Analytics History"
|
||
description="Purge all pattern score history, regime clusters, embeddings, cycle run logs, and macro/geo alert history."
|
||
tables={['pattern_score_history', 'regime_clusters', 'pattern_embeddings', 'cycle_runs', 'macro_regime_history', 'geo_alert_history']}
|
||
endpoint="/api/analytics/purge-all"
|
||
color="red"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="VaR & PnL Snapshots"
|
||
description="Delete all Value-at-Risk snapshots and daily PnL snapshots."
|
||
tables={['var_snapshots', 'pnl_snapshots']}
|
||
endpoint="/api/var/purge-all"
|
||
color="orange"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="Pattern Lab Runs"
|
||
description="Delete all Pattern Lab backtest runs (event presets + instrument scans). Does not affect the Pattern Library."
|
||
tables={['backtest_lab_runs']}
|
||
endpoint="/api/pattern-lab/purge-all"
|
||
color="orange"
|
||
/>
|
||
|
||
<PurgeButton
|
||
label="Pattern Library"
|
||
description="Delete all custom and backtested patterns from the library. Trade ideas will regenerate from scratch on the next cycle. Does not affect Pattern Lab run history."
|
||
tables={['custom_patterns']}
|
||
endpoint="/api/patterns/purge-all"
|
||
color="red"
|
||
/>
|
||
|
||
<div className="mt-2 p-3 border border-slate-700/40 rounded-lg bg-dark-700/30">
|
||
<p className="text-xs text-slate-500">
|
||
<span className="font-semibold text-slate-400">Note:</span> Trade Ideas are computed live from the Pattern Library — they have no separate table. Risk profiles, config, API keys, and the Knowledge Base are managed from their respective sections.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|