feat: Config tab "Options — Paramètres" with IV gate controls + exit params

- New tab replaces "Journal & Sortie" — consolidates all options-specific settings
- IV Gate section: toggle on/off + 3 sliders (IVR High/Extreme/Skew threshold)
  with live color-coded summary and interdependency guards (high < extreme)
- Exit params section: target, stop-loss, reversal mode + threshold (moved from removed journal tab)
- Backend: GET/PUT /api/config/options-gate endpoint reads/writes 4 config DB keys
- useApi.ts: useOptionsGate + useSaveOptionsGate hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 10:23:31 +02:00
parent c7ccf237d7
commit 39da3b8945
3 changed files with 282 additions and 77 deletions

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter
from pydantic import BaseModel
from typing import Dict, Any, Optional
import os
from services.database import get_all_config, set_config, get_sources, update_sources, get_analysis_config, save_analysis_config
from services.database import get_all_config, set_config, get_config, get_sources, update_sources, get_analysis_config, save_analysis_config
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -89,3 +89,33 @@ def save_analysis_config_endpoint(req: AnalysisConfigRequest):
current["template"] = req.template
save_analysis_config(current)
return {"status": "ok", "config": current}
class OptionsGateRequest(BaseModel):
iv_gate_enabled: Optional[bool] = None
iv_gate_ivr_high: Optional[float] = None
iv_gate_ivr_extreme: Optional[float] = None
iv_gate_skew_threshold: Optional[float] = None
@router.get("/options-gate")
def get_options_gate():
return {
"iv_gate_enabled": (get_config("iv_gate_enabled") or "true").lower() == "true",
"iv_gate_ivr_high": float(get_config("iv_gate_ivr_high") or 60),
"iv_gate_ivr_extreme": float(get_config("iv_gate_ivr_extreme") or 80),
"iv_gate_skew_threshold": float(get_config("iv_gate_skew_threshold") or 8),
}
@router.put("/options-gate")
def save_options_gate(req: OptionsGateRequest):
if req.iv_gate_enabled is not None:
set_config("iv_gate_enabled", "true" if req.iv_gate_enabled else "false")
if req.iv_gate_ivr_high is not None:
set_config("iv_gate_ivr_high", str(req.iv_gate_ivr_high))
if req.iv_gate_ivr_extreme is not None:
set_config("iv_gate_ivr_extreme", str(req.iv_gate_ivr_extreme))
if req.iv_gate_skew_threshold is not None:
set_config("iv_gate_skew_threshold", str(req.iv_gate_skew_threshold))
return {"status": "ok"}

View File

@@ -459,6 +459,26 @@ export const useSaveExitDefaults = () => {
})
}
export const useOptionsGate = () =>
useQuery({
queryKey: ['options-gate-config'],
queryFn: () => api.get('/config/options-gate').then(r => r.data),
staleTime: 60_000,
})
export const useSaveOptionsGate = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: {
iv_gate_enabled?: boolean
iv_gate_ivr_high?: number
iv_gate_ivr_extreme?: number
iv_gate_skew_threshold?: number
}) => api.put('/config/options-gate', body).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['options-gate-config'] }),
})
}
export const useSimPortfolioRisk = () =>
useQuery({
queryKey: ['journal-portfolio-risk'],

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults } from '../hooks/useApi'
import { Settings, Key, Globe, CheckCircle, XCircle, AlertCircle, Save, Eye, EyeOff, Brain, SlidersHorizontal, RefreshCw, Zap, Plus, Trash2, Pencil, X, Lock, Gauge, DollarSign } from 'lucide-react'
import { useSources, useUpdateSources, useUpdateApiKeys, useConfig, useAiStatus, useAnalysisConfig, useSaveAnalysisConfig, useCycleStatus, useUpdateCycleConfig, useTriggerCycle, useRiskProfiles, useUpsertProfile, useDeleteProfile, useExitDefaults, useSaveExitDefaults, useOptionsGate, useSaveOptionsGate } 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 } from 'lucide-react'
import clsx from 'clsx'
const API = ''
@@ -343,6 +343,8 @@ export default function Config() {
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('')
@@ -351,7 +353,13 @@ export default function Config() {
const [fredKey, setFredKey] = useState('')
const [showKeys, setShowKeys] = useState(false)
const [savedMsg, setSavedMsg] = useState('')
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'journal' | 'profiles'>('ia')
const [configTab, setConfigTab] = useState<'ia' | 'cycle' | 'sources' | 'options' | 'profiles'>('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)
// Analysis config local state
const [analysisTopN, setAnalysisTopN] = useState(10)
@@ -437,6 +445,15 @@ export default function Config() {
}
}, [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])
const displaySources = localSources ?? sources ?? {}
const toggleSource = (key: string) => {
@@ -471,7 +488,7 @@ export default function Config() {
{ key: 'ia', label: 'IA & Analyse', icon: Brain },
{ key: 'cycle', label: 'Auto-Cycle & Logging', icon: Zap },
{ key: 'sources', label: 'Sources', icon: Globe },
{ key: 'journal', label: 'Journal & Sortie', icon: Lock },
{ key: 'options', label: 'Options — Paramètres', icon: TrendingUp },
{ key: 'profiles', label: 'Profils de risque', icon: SlidersHorizontal },
] as const
@@ -1038,88 +1055,226 @@ export default function Config() {
</div>
)}
{/* ── Journal & Sortie ── */}
{configTab === 'journal' && (
<div className="card">
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-4">
<Lock className="w-4 h-4 text-amber-400" /> Paramètres de sortie des trades
</h2>
<p className="text-xs text-slate-500 mb-4">
Valeurs par défaut pour les objectifs et stop-loss. Chaque trade peut avoir ses propres seuils (Journal Ouverts).
</p>
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">
Objectif de gain : <span className="text-emerald-400 font-mono font-bold">+{exitTarget}%</span>
<span className="text-slate-600 ml-1">(P&L sous-jacent)</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>
{/* ── 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 Filtres d'entrée
</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 actif' : ' Gate désactivé'}
</button>
</div>
<p className="text-xs text-slate-500 mb-5">
Bloque automatiquement les trades avec verdict <span className="text-red-400 font-semibold">ALERT</span> avant
qu'ils soient loggés dans le journal. Un trade ALERT est une stratégie incompatible avec le régime de volatilité actuel
(ex: Long Call à IVR 100%). Les trades bloqués apparaissent dans les trades skippés avec la raison <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">
Seuil vol chère (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 &lt; {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">
Seuil vol extrême (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 &gt; {ivGateExtreme}% <span className="text-red-400 font-semibold">BLOQUÉ</span> naked long vol ALERT</div>
<div>Straddle / Strangle ALERT dès IVR &gt; {ivGateExtreme}% (pénalité ×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">
Seuil skew put (protection chère)
</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 (très sensible)</span><span>20pts (très tolérant)</span>
</div>
<div className="text-[10px] text-slate-500 mt-1.5">
Skew put &gt; {ivGateSkew}pts puts très chers, signal WARN sur 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">Résumé du gate actuel</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 &lt; <span className="text-emerald-400 font-mono">{ivGateHigh}%</span> Toute stratégie 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 préférés</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 &gt; <span className="text-red-400 font-mono">{ivGateExtreme}%</span> Long naked <strong className="text-red-400">BLOQUÉ</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 &gt; <span className="text-orange-400 font-mono">{ivGateSkew}pts</span> Puts chers (WARN)</span>
</div>
</div>
</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">(P&L sous-jacent)</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 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 sauvegardé'); 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 ? 'Sauvegarde...' : 'Sauvegarder le gate'}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">Mode signal IA de retournement</label>
<div className="flex gap-2">
{[
{ value: 'badge_only', label: '🔔 Badge uniquement' },
{ value: 'auto', label: '⚡ Auto (futur)' },
].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>
))}
{/* ── 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" /> Paramètres de sortie des trades
</h2>
<p className="text-xs text-slate-500 mb-5">
Valeurs par défaut pour les objectifs et stop-loss. Chaque trade peut avoir ses propres seuils (Journal Ouverts).
</p>
<div className="grid grid-cols-2 gap-6 mb-4">
<div>
<label className="text-xs text-slate-500 mb-2 block">
Objectif de gain : <span className="text-emerald-400 font-mono font-bold">+{exitTarget}%</span>
<span className="text-slate-600 ml-1">(P&L sous-jacent)</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 className="text-[10px] text-slate-600 mt-1">
Badge only : alerte visible, vous confirmez manuellement. Auto : fermeture proposée.
<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">(P&L sous-jacent)</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>
<label className="text-xs text-slate-500 mb-2 block">
Seuil retournement signal : <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 (sensible)</span><span>60pts (tolérant)</span>
<div className="grid grid-cols-2 gap-6 mb-5">
<div>
<label className="text-xs text-slate-500 mb-2 block">Mode signal IA de retournement</label>
<div className="flex gap-2">
{[
{ value: 'badge_only', label: '🔔 Badge uniquement' },
{ value: 'auto', label: '⚡ Auto (futur)' },
].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 : alerte visible, vous confirmez manuellement. Auto : fermeture proposée.
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-2 block">
Seuil retournement signal : <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 (sensible)</span><span>60pts (tolérant)</span>
</div>
</div>
</div>
<button
onClick={() => saveExitDefaults(
{ target_pct: exitTarget, stop_loss_pct: exitStop,
signal_reversal_mode: exitSignalMode, signal_reversal_threshold: exitSignalThreshold },
{ onSuccess: () => { setSavedMsg('Paramètres de sortie sauvegardés'); 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 ? 'Sauvegarde...' : 'Sauvegarder les seuils'}
</button>
</div>
<button
onClick={() => saveExitDefaults(
{ target_pct: exitTarget, stop_loss_pct: exitStop,
signal_reversal_mode: exitSignalMode, signal_reversal_threshold: exitSignalThreshold },
{ onSuccess: () => { setSavedMsg('Paramètres de sortie sauvegardés'); 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 ? 'Sauvegarde...' : 'Sauvegarder les seuils'}
</button>
</div>
)}