@@ -0,0 +1,697 @@
import { useEffect , useMemo , useState } from 'react'
import {
LineChart , Line , XAxis , YAxis , CartesianGrid , Tooltip , Legend , ReferenceLine , ResponsiveContainer ,
} from 'recharts'
import { Layers , Plus , Trash2 , RefreshCw , AlertTriangle , Search , Save , FolderOpen , X } from 'lucide-react'
import clsx from 'clsx'
import {
useOptionChainSlice , usePriceStrategy , useOptimizeStrategy ,
useScenarios , useSaveScenario , useDeleteScenario ,
useSavedStrategies , useSaveStrategyRecord , useDeleteSavedStrategy ,
type StrategyLeg , type StrategyScenario , type PriceCombo , type StrategyCandidate ,
type OptimizeConstraints , type SavedScenario ,
} from '../hooks/useApi'
const STRIKE_PCTS = [ 80 , 85 , 90 , 95 , 100 , 105 , 110 , 115 , 120 ]
const DELTA_NEUTRAL_THRESHOLD = 0.15
// ── Helpers ───────────────────────────────────────────────────────────────────
function emptyLeg ( expiryDate : string , daysToExpiry : number , strike : number ) : StrategyLeg {
return { expiry_date : expiryDate , days_to_expiry : daysToExpiry , strike , option_type : 'call' , position : 'long' , quantity : 1 }
}
function fmtMoney ( v : number | null | undefined ) {
if ( v == null ) return '—'
return ` ${ v >= 0 ? '+' : '' } ${ v . toFixed ( 2 ) } `
}
function pnlColor ( v : number | null | undefined ) {
if ( v == null ) return 'text-slate-400'
return v >= 0 ? 'text-emerald-400' : 'text-red-400'
}
/** Rough client-side smile preview (avg call/put IV at nearest strike) — mirrors the
* cubic-spline surface computed server-side closely enough to preview shocks live. */
function estimateBaseIv ( chain : any , daysToExpiry : number , strikePct : number , spot : number ) : number | null {
if ( ! chain ) return null
const exp = chain . expiries . reduce ( ( best : any , e : any ) = >
Math . abs ( e . days_to_expiry - daysToExpiry ) < Math . abs ( ( best ? . days_to_expiry ? ? Infinity ) - daysToExpiry ) ? e : best , null )
if ( ! exp ) return null
const targetStrike = spot * ( strikePct / 100 )
const rows = [ . . . exp . calls , . . . exp . puts ] . filter ( ( r : any ) = > r . iv > 0.01 )
if ( ! rows . length ) return null
const nearest = rows . reduce ( ( best : any , r : any ) = >
Math . abs ( r . strike - targetStrike ) < Math . abs ( best . strike - targetStrike ) ? r : best )
return nearest . iv
}
// ── Payoff chart ──────────────────────────────────────────────────────────────
function PayoffChart ( { priced , spot , scenarioSpot } : { priced : PriceCombo ; spot : number ; scenarioSpot : number } ) {
const data = priced . at_expiry . map ( ( p , i ) = > ( {
underlying : p.underlying ,
expiry : p.pnl ,
scenario : priced.at_scenario [ i ] ? . pnl ,
} ) )
return (
< ResponsiveContainer width = "100%" height = { 280 } >
< LineChart data = { data } margin = { { top : 8 , right : 16 , left : 0 , bottom : 0 } } >
< CartesianGrid strokeDasharray = "3 3" stroke = "#1e2d4d" / >
< XAxis dataKey = "underlying" tick = { { fill : '#475569' , fontSize : 10 } } tickLine = { false }
tickFormatter = { ( v ) = > v . toFixed ( 0 ) } / >
< YAxis tick = { { fill : '#475569' , fontSize : 10 } } tickLine = { false } axisLine = { false }
tickFormatter = { ( v ) = > ` ${ v } ` } / >
< Tooltip
contentStyle = { { background : '#0f1623' , border : '1px solid #1e2d4d' , fontSize : 11 } }
labelFormatter = { ( v ) = > ` Sous-jacent: ${ Number ( v ) . toFixed ( 2 ) } ` }
formatter = { ( v : number , name : string ) = > [ fmtMoney ( v ) , name ] }
/ >
< Legend wrapperStyle = { { fontSize : 11 } } / >
< ReferenceLine y = { 0 } stroke = "#475569" strokeDasharray = "4 4" / >
< ReferenceLine x = { spot } stroke = "#3b82f6" strokeDasharray = "2 2" label = { { value : 'Spot' , fill : '#3b82f6' , fontSize : 9 , position : 'top' } } / >
< ReferenceLine x = { scenarioSpot } stroke = "#f59e0b" strokeDasharray = "2 2" label = { { value : 'Scénario J+8' , fill : '#f59e0b' , fontSize : 9 , position : 'insideTopRight' } } / >
< Line type = "monotone" dataKey = "expiry" name = "À échéance" stroke = "#3b82f6" strokeWidth = { 2 } dot = { false } / >
< Line type = "monotone" dataKey = "scenario" name = "À J+8 (scénario)" stroke = "#f59e0b" strokeWidth = { 2 } dot = { false } / >
< / LineChart >
< / ResponsiveContainer >
)
}
function GreeksTile ( { label , now , scenario } : { label : string ; now : number ; scenario : number } ) {
return (
< div className = "card-sm" >
< div className = "stat-label" > { label } < / div >
< div className = "flex items-baseline gap-2 mt-1" >
< span className = "text-lg font-bold text-white" > { now . toFixed ( 4 ) } < / span >
< span className = "text-xs text-slate-500" > → < / span >
< span className = { clsx ( 'text-sm font-semibold' , scenario >= now ? 'text-emerald-400' : 'text-red-400' ) } > { scenario . toFixed ( 4 ) } < / span >
< / div >
< / div >
)
}
// ── Scenario panel ────────────────────────────────────────────────────────────
function ScenarioPanel ( {
symbol , setSymbol , horizonDays , setHorizonDays , scenario , setScenario ,
} : {
symbol : string ; setSymbol : ( v : string ) = > void
horizonDays : number ; setHorizonDays : ( v : number ) = > void
scenario : StrategyScenario ; setScenario : ( v : StrategyScenario ) = > void
} ) {
const slider = (
key : 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_shift' ,
label : string , min : number , max : number , step : number , fmt : ( v : number ) = > string ,
) = > (
< div >
< div className = "flex items-center justify-between text-xs text-slate-400 mb-1" >
< span > { label } < / span >
< span className = "text-white font-semibold" > { fmt ( scenario [ key ] ) } < / span >
< / div >
< input
type = "range" min = { min } max = { max } step = { step } value = { scenario [ key ] }
onChange = { ( e ) = > setScenario ( { . . . scenario , [ key ] : parseFloat ( e . target . value ) } ) }
className = "w-full accent-blue-500"
/ >
< / div >
)
return (
< div className = "card space-y-4" >
< div className = "flex items-center gap-3" >
< div className = "flex-1" >
< label className = "stat-label block mb-1" > Symbole < / label >
< input
value = { symbol }
onChange = { ( e ) = > setSymbol ( e . target . value . toUpperCase ( ) ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
placeholder = "SPY, QQQ, GLD…"
/ >
< / div >
< div className = "w-28" >
< label className = "stat-label block mb-1" > Horizon ( j ) < / label >
< input
type = "number" min = { 1 } max = { 90 } value = { horizonDays }
onChange = { ( e ) = > setHorizonDays ( parseInt ( e . target . value ) || 8 ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
/ >
< / div >
< / div >
< div className = "grid grid-cols-2 gap-4" >
{ slider ( 'spot_shock_pct' , 'Choc spot' , - 20 , 20 , 0.5 , ( v ) = > ` ${ v >= 0 ? '+' : '' } ${ v . toFixed ( 1 ) } % ` ) }
{ slider ( 'iv_level_shift' , 'Choc niveau IV' , - 0.15 , 0.15 , 0.005 , ( v ) = > ` ${ v >= 0 ? '+' : '' } ${ ( v * 100 ) . toFixed ( 1 ) } pts ` ) }
{ slider ( 'skew_tilt' , 'Tilt skew' , - 0.1 , 0.1 , 0.005 , ( v ) = > v . toFixed ( 3 ) ) }
{ slider ( 'term_shift' , 'Choc terme (/30j)' , - 0.1 , 0.1 , 0.005 , ( v ) = > ` ${ v >= 0 ? '+' : '' } ${ ( v * 100 ) . toFixed ( 1 ) } pts ` ) }
< / div >
< / div >
)
}
// ── Manual grid override ──────────────────────────────────────────────────────
function ScenarioGrid ( {
chain , spot , scenario , setScenario ,
} : {
chain : any ; spot : number ; scenario : StrategyScenario ; setScenario : ( v : StrategyScenario ) = > void
} ) {
if ( ! chain ) return null
const overrides = scenario . manual_grid || [ ]
const cellValue = ( daysToExpiry : number , strikePct : number ) : number | null = > {
const hit = overrides . find ( o = > o . days_to_expiry === daysToExpiry && o . strike_pct === strikePct )
if ( hit ) return hit . iv
const base = estimateBaseIv ( chain , daysToExpiry , strikePct , spot )
if ( base == null ) return null
const moneyness = Math . log ( strikePct / 100 )
return Math . max ( 0.01 , base + scenario . iv_level_shift + scenario . skew_tilt * moneyness + scenario . term_shift * ( daysToExpiry / 30 ) )
}
const setOverride = ( daysToExpiry : number , strikePct : number , iv : number | null ) = > {
const next = overrides . filter ( o = > ! ( o . days_to_expiry === daysToExpiry && o . strike_pct === strikePct ) )
if ( iv != null ) next . push ( { days_to_expiry : daysToExpiry , strike_pct : strikePct , iv } )
setScenario ( { . . . scenario , manual_grid : next } )
}
const isOverridden = ( daysToExpiry : number , strikePct : number ) = >
overrides . some ( o = > o . days_to_expiry === daysToExpiry && o . strike_pct === strikePct )
return (
< div className = "card" >
< div className = "flex items-center justify-between mb-2" >
< div className = "stat-label" > Grille IV scénario ( é ditable — clic sur une cellule ) < / div >
{ overrides . length > 0 && (
< button onClick = { ( ) = > setScenario ( { . . . scenario , manual_grid : [ ] } ) } className = "text-xs text-slate-500 hover:text-slate-300" >
Réinitialiser overrides
< / button >
) }
< / div >
< div className = "overflow-x-auto" >
< table className = "w-full text-xs" >
< thead >
< tr className = "text-slate-500" >
< th className = "text-left py-1 pr-3" > Expiry / Strike % < / th >
{ STRIKE_PCTS . map ( p = > < th key = { p } className = "px-2 py-1 text-right" > { p } % < / th > ) }
< / tr >
< / thead >
< tbody >
{ chain . expiries . map ( ( exp : any ) = > (
< tr key = { exp . expiry_date } className = "border-t border-slate-700/30" >
< td className = "py-1 pr-3 text-slate-400 whitespace-nowrap" > { exp . expiry_date } ( { exp . days_to_expiry } j ) < / td >
{ STRIKE_PCTS . map ( pct = > {
const v = cellValue ( exp . days_to_expiry , pct )
const overridden = isOverridden ( exp . days_to_expiry , pct )
return (
< td key = { pct } className = "px-1 py-1" >
< input
type = "number" step = { 0.005 }
value = { v != null ? Math . round ( v * 1000 ) / 1000 : '' }
onChange = { ( e ) = > {
const val = e . target . value === '' ? null : parseFloat ( e . target . value )
setOverride ( exp . days_to_expiry , pct , val )
} }
className = { clsx (
'w-16 text-right bg-dark-700 border rounded px-1 py-0.5' ,
overridden ? 'border-amber-500 text-amber-300' : 'border-slate-700/40 text-slate-300' ,
) }
/ >
< / td >
)
} ) }
< / tr >
) ) }
< / tbody >
< / table >
< / div >
< / div >
)
}
// ── Manual leg builder ────────────────────────────────────────────────────────
function LegRow ( {
leg , chain , onChange , onRemove ,
} : {
leg : StrategyLeg ; chain : any
onChange : ( leg : StrategyLeg ) = > void
onRemove : ( ) = > void
} ) {
const expiry = chain ? . expiries . find ( ( e : any ) = > e . expiry_date === leg . expiry_date )
const rows = expiry ? ( leg . option_type === 'call' ? expiry.calls : expiry.puts ) : [ ]
return (
< div className = "grid grid-cols-12 gap-2 items-center text-xs" >
< select
className = "col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value = { leg . expiry_date }
onChange = { ( e ) = > {
const exp = chain . expiries . find ( ( x : any ) = > x . expiry_date === e . target . value )
onChange ( { . . . leg , expiry_date : e.target.value , days_to_expiry : exp?.days_to_expiry ? ? leg . days_to_expiry } )
} }
>
{ chain ? . expiries . map ( ( e : any ) = > (
< option key = { e . expiry_date } value = { e . expiry_date } > { e . expiry_date } ( { e . days_to_expiry } j ) < / option >
) ) }
< / select >
< select
className = "col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value = { leg . option_type }
onChange = { ( e ) = > onChange ( { . . . leg , option_type : e.target.value as 'call' | 'put' } ) }
>
< option value = "call" > Call < / option >
< option value = "put" > Put < / option >
< / select >
< select
className = "col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value = { leg . strike }
onChange = { ( e ) = > onChange ( { . . . leg , strike : parseFloat ( e . target . value ) } ) }
>
{ rows . map ( ( r : any ) = > (
< option key = { r . strike } value = { r . strike } > { r . strike } ( bid { r . bid } / ask { r . ask } ) < / option >
) ) }
< / select >
< select
className = "col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value = { leg . position }
onChange = { ( e ) = > onChange ( { . . . leg , position : e.target.value as 'long' | 'short' } ) }
>
< option value = "long" > Achat < / option >
< option value = "short" > Vente < / option >
< / select >
< input
type = "number" min = { 1 } value = { leg . quantity }
onChange = { ( e ) = > onChange ( { . . . leg , quantity : parseInt ( e . target . value ) || 1 } ) }
className = "col-span-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
/ >
< button onClick = { onRemove } className = "col-span-1 text-slate-500 hover:text-red-400 flex justify-center" >
< Trash2 className = "w-3.5 h-3.5" / >
< / button >
< / div >
)
}
// ── Optimizer panel ───────────────────────────────────────────────────────────
const OBJECTIVES : { value : OptimizeConstraints [ 'objective' ] ; label : string } [ ] = [
{ value : 'net_pnl' , label : 'P&L net max' } ,
{ value : 'return_on_risk' , label : 'Retour sur risque (P&L / perte max)' } ,
{ value : 'prob_weighted' , label : 'Espérance pondérée par probabilité' } ,
]
function OptimizerPanel ( {
constraints , setConstraints , onRun , isRunning ,
} : {
constraints : OptimizeConstraints ; setConstraints : ( v : OptimizeConstraints ) = > void
onRun : ( ) = > void ; isRunning : boolean
} ) {
return (
< div className = "card space-y-3" >
< div className = "stat-label" > Optimiseur — contraintes & amp ; objectif < / div >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-3 text-xs" >
< div >
< label className = "text-slate-400 block mb-1" > Max jambes < / label >
< select
value = { constraints . max_legs }
onChange = { ( e ) = > setConstraints ( { . . . constraints , max_legs : parseInt ( e . target . value ) } ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
>
{ [ 1 , 2 , 3 , 4 ] . map ( n = > < option key = { n } value = { n } > { n } < / option > ) }
< / select >
< / div >
< div >
< label className = "text-slate-400 block mb-1" > Seuil neutralité Δ < / label >
< input
type = "number" step = { 0.01 } min = { 0 } value = { constraints . delta_threshold }
onChange = { ( e ) = > setConstraints ( { . . . constraints , delta_threshold : parseFloat ( e . target . value ) || 0 } ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
/ >
< / div >
< div >
< label className = "text-slate-400 block mb-1" > Plafond perte max ( optionnel ) < / label >
< input
type = "number" placeholder = "illimité"
value = { constraints . max_loss_cap ? ? '' }
onChange = { ( e ) = > setConstraints ( { . . . constraints , max_loss_cap : e.target.value === '' ? null : parseFloat ( e . target . value ) } ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
/ >
< / div >
< div >
< label className = "text-slate-400 block mb-1" > Objectif < / label >
< select
value = { constraints . objective }
onChange = { ( e ) = > setConstraints ( { . . . constraints , objective : e.target.value as OptimizeConstraints [ 'objective' ] } ) }
className = "w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
>
{ OBJECTIVES . map ( o = > < option key = { o . value } value = { o . value } > { o . label } < / option > ) }
< / select >
< / div >
< / div >
< button
onClick = { onRun }
disabled = { isRunning }
className = "flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
>
< Search className = { clsx ( 'w-3.5 h-3.5' , isRunning && 'animate-pulse' ) } / >
{ isRunning ? 'Recherche parmi des milliers de combinaisons…' : 'Trouver la stratégie optimale' }
< / button >
< / div >
)
}
function ResultsTable ( { results , onSelect } : { results : StrategyCandidate [ ] ; onSelect : ( c : StrategyCandidate ) = > void } ) {
if ( ! results . length ) return < div className = "card-sm text-xs text-slate-500" > Aucun candidat ne satisfait les contraintes — é largissez le seuil de delta ou le plafond de perte . < / div >
return (
< div className = "card overflow-x-auto" >
< div className = "stat-label mb-2" > { results . length } candidats classés < / div >
< table className = "w-full text-xs" >
< thead >
< tr className = "text-slate-500 text-left" >
< th className = "py-1 pr-3" > Structure < / th >
< th className = "py-1 pr-3" > Jambes < / th >
< th className = "py-1 pr-3 text-right" > Score < / th >
< th className = "py-1 pr-3 text-right" > P & amp ; L net < / th >
< th className = "py-1 pr-3 text-right" > Max gain < / th >
< th className = "py-1 pr-3 text-right" > Max perte < / th >
< th className = "py-1 pr-3 text-right" > Δ net < / th >
< th className = "py-1 pr-1" > < / th >
< / tr >
< / thead >
< tbody >
{ results . map ( ( r , i ) = > (
< tr key = { i } className = "border-t border-slate-700/30 hover:bg-dark-700/40 cursor-pointer" onClick = { ( ) = > onSelect ( r ) } >
< td className = "py-1.5 pr-3 text-slate-200" > { r . template_name } < / td >
< td className = "py-1.5 pr-3 text-slate-400" > { r . legs . length } < / td >
< td className = "py-1.5 pr-3 text-right font-semibold text-white" > { r . score . toFixed ( 2 ) } < / td >
< td className = { clsx ( 'py-1.5 pr-3 text-right font-semibold' , pnlColor ( r . net_pnl ) ) } > { fmtMoney ( r . net_pnl ) } < / td >
< td className = "py-1.5 pr-3 text-right text-emerald-400" > { r . max_gain != null ? fmtMoney ( r . max_gain ) : '∞' } < / td >
< td className = "py-1.5 pr-3 text-right text-red-400" > { r . max_loss != null ? fmtMoney ( r . max_loss ) : '−∞' } < / td >
< td className = "py-1.5 pr-3 text-right text-slate-400" > { r . net_delta_now . toFixed ( 3 ) } < / td >
< td className = "py-1.5 pr-1 text-blue-400 text-right" > Charger → < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
)
}
// ── Scenario save/load ────────────────────────────────────────────────────────
function ScenarioLibrary ( {
symbol , scenario , onLoad ,
} : {
symbol : string ; scenario : StrategyScenario ; onLoad : ( s : SavedScenario ) = > void
} ) {
const { data : saved = [ ] } = useScenarios ( symbol )
const saveScenario = useSaveScenario ( )
const deleteScenario = useDeleteScenario ( )
const [ label , setLabel ] = useState ( '' )
return (
< div className = "card-sm space-y-2" >
< div className = "flex items-center gap-2" >
< input
value = { label } onChange = { ( e ) = > setLabel ( e . target . value ) } placeholder = "Nom du scénario…"
className = "flex-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-xs text-white"
/ >
< button
onClick = { ( ) = > { saveScenario . mutate ( { . . . scenario , label } ) ; setLabel ( '' ) } }
disabled = { ! label || saveScenario . isPending }
className = "flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2 py-1 rounded disabled:opacity-40"
>
< Save className = "w-3 h-3" / > Sauvegarder
< / button >
< / div >
{ saved . length > 0 && (
< div className = "flex flex-wrap gap-1.5" >
{ saved . map ( s = > (
< span key = { s . id } className = "badge-blue flex items-center gap-1" >
< button onClick = { ( ) = > onLoad ( s ) } className = "flex items-center gap-1" >
< FolderOpen className = "w-3 h-3" / > { s . label || s . id }
< / button >
< button onClick = { ( ) = > deleteScenario . mutate ( s . id ) } > < X className = "w-3 h-3" / > < / button >
< / span >
) ) }
< / div >
) }
< / div >
)
}
// ── Saved strategies library ──────────────────────────────────────────────────
function SavedStrategiesLibrary ( { symbol , onLoad } : { symbol : string ; onLoad : ( legs : StrategyLeg [ ] , templateName : string ) = > void } ) {
const { data : saved = [ ] } = useSavedStrategies ( symbol )
const deleteStrategy = useDeleteSavedStrategy ( )
if ( ! saved . length ) return null
return (
< div className = "card-sm space-y-2" >
< div className = "stat-label" > Stratégies sauvegardées ( { symbol } ) < / div >
< div className = "space-y-1" >
{ saved . map ( s = > (
< div key = { s . id } className = "flex items-center justify-between text-xs bg-dark-700/50 rounded px-2 py-1.5" >
< button onClick = { ( ) = > onLoad ( s . legs , s . template_name ) } className = "flex items-center gap-2 text-slate-300 hover:text-white" >
< FolderOpen className = "w-3 h-3 text-blue-400" / >
< span > { s . template_name } < / span >
< span className = { pnlColor ( s . net_pnl_scenario ) } > { fmtMoney ( s . net_pnl_scenario ) } < / span >
< span className = "text-slate-600" > { s . legs . length } jambes < / span >
< / button >
< button onClick = { ( ) = > deleteStrategy . mutate ( s . id ) } className = "text-slate-500 hover:text-red-400" >
< Trash2 className = "w-3 h-3" / >
< / button >
< / div >
) ) }
< / div >
< / div >
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function StrategyBuilder() {
const [ symbol , setSymbol ] = useState ( 'SPY' )
const [ horizonDays , setHorizonDays ] = useState ( 8 )
const [ scenario , setScenario ] = useState < StrategyScenario > ( {
symbol : 'SPY' , horizon_days : 8 , spot_shock_pct : 0 , iv_level_shift : 0 , skew_tilt : 0 , term_shift : 0 , manual_grid : [ ] ,
} )
const [ legs , setLegs ] = useState < StrategyLeg [ ] > ( [ ] )
const [ constraints , setConstraints ] = useState < OptimizeConstraints > ( {
max_legs : 4 , delta_threshold : 0.15 , max_loss_cap : null , objective : 'net_pnl' , top_n : 20 ,
} )
const [ activeTemplate , setActiveTemplate ] = useState < string | null > ( null )
const { data : chain , isLoading : chainLoading , isError : chainError , refetch : refetchChain , isFetching } =
useOptionChainSlice ( symbol , horizonDays , 3 )
useEffect ( ( ) = > {
setScenario ( s = > ( { . . . s , symbol , horizon_days : horizonDays } ) )
} , [ symbol , horizonDays ] )
useEffect ( ( ) = > {
if ( chain && chain . expiries . length && legs . length === 0 ) {
const exp = chain . expiries [ 0 ]
const atm = exp . calls . reduce ( ( best : any , r : any ) = >
Math . abs ( r . strike - chain . spot ) < Math . abs ( best . strike - chain . spot ) ? r : best , exp . calls [ 0 ] )
if ( atm ) setLegs ( [ emptyLeg ( exp . expiry_date , exp . days_to_expiry , atm . strike ) ] )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ chain ] )
const priceMutation = usePriceStrategy ( )
useEffect ( ( ) = > {
if ( ! chain || legs . length === 0 ) return
const t = setTimeout ( ( ) = > {
priceMutation . mutate ( { scenario , legs } )
} , 400 )
return ( ) = > clearTimeout ( t )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ JSON . stringify ( scenario ) , JSON . stringify ( legs ) , chain ] )
const priced = priceMutation . data
const isNonDirectional = useMemo ( ( ) = > {
if ( ! priced ) return null
return Math . abs ( priced . net_delta_now ) <= DELTA_NEUTRAL_THRESHOLD
} , [ priced ] )
const addLeg = ( ) = > {
if ( ! chain || legs . length >= 4 ) return
const exp = chain . expiries [ 0 ]
setLegs ( [ . . . legs , emptyLeg ( exp . expiry_date , exp . days_to_expiry , chain . spot ) ] )
}
const optimizeMutation = useOptimizeStrategy ( )
const saveStrategy = useSaveStrategyRecord ( )
const handleOptimize = ( ) = > {
setActiveTemplate ( null )
optimizeMutation . mutate ( { scenario , constraints } )
}
const handleSelectCandidate = ( c : StrategyCandidate ) = > {
setActiveTemplate ( c . template_name )
setLegs ( c . legs )
}
const handleLoadScenario = ( s : SavedScenario ) = > {
setSymbol ( s . symbol )
setHorizonDays ( s . horizon_days )
setScenario ( {
symbol : s . symbol , horizon_days : s.horizon_days , spot_shock_pct : s.spot_shock_pct ,
iv_level_shift : s.iv_level_shift , skew_tilt : s.skew_tilt , term_shift : s.term_shift ,
manual_grid : s.manual_grid ,
} )
}
const handleSaveStrategy = ( ) = > {
if ( ! priced ) return
saveStrategy . mutate ( {
symbol , template_name : activeTemplate || 'Manuel' , objective : constraints.objective , legs ,
entry_cost : priced.entry_cost , max_gain : priced.max_gain , max_loss : priced.max_loss ,
net_pnl_scenario : priced.net_pnl , net_delta : priced.net_delta_now ,
} )
}
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" >
< Layers className = "w-5 h-5 text-blue-400" / > Strategy Builder
< / h1 >
< p className = "text-xs text-slate-500 mt-0.5" >
Scénario spot / IV / surface à J + N · builder manuel 1 - 4 jambes · payoff & amp ; greeks avec spread broker réel
< / p >
< / div >
< button
onClick = { ( ) = > refetchChain ( ) }
disabled = { isFetching }
className = "flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
< RefreshCw className = { clsx ( 'w-3.5 h-3.5' , isFetching && 'animate-spin' ) } / >
{ isFetching ? 'Chargement...' : 'Rafraîchir la chaîne' }
< / button >
< / div >
{ chainError && (
< div className = "px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300 flex items-center gap-2" >
< AlertTriangle className = "w-4 h-4" / > Chaîne d 'options indisponible pour {symbol}. Essayez un autre symbole (ETF/action optionable).
</div>
)}
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
scenario={scenario} setScenario={setScenario} />
<ScenarioLibrary symbol={symbol} scenario={scenario} onLoad={handleLoadScenario} />
<SavedStrategiesLibrary symbol={symbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({symbol})…</div>}
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<div className="stat-label">Jambes (1-4) — Spot {chain.spot}</div>
<button
onClick={addLeg}
disabled={legs.length >= 4}
className="flex items-center gap-1 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-2.5 py-1 rounded"
>
<Plus className="w-3.5 h-3.5" /> Ajouter une jambe
</button>
</div>
<div className="space-y-2">
{legs.map((leg, i) => (
<LegRow
key={i} leg={leg} chain={chain}
onChange={(l) => setLegs(legs.map((x, j) => j === i ? l : x))}
onRemove={() => setLegs(legs.filter((_, j) => j !== i))}
/>
))}
{legs.length === 0 && <div className="text-xs text-slate-500">Aucune jambe — ajoutez-en une pour commencer.</div>}
</div>
</div>
)}
{chain && (
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={handleOptimize} isRunning={optimizeMutation.isPending} />
)}
{optimizeMutation.isError && (
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
Erreur lors de l' optimisation .
< / div >
) }
{ optimizeMutation . data && (
< ResultsTable results = { optimizeMutation . data } onSelect = { handleSelectCandidate } / >
) }
{ priceMutation . isPending && < div className = "card-sm text-xs text-slate-500" > Calcul en cours … < / div > }
{ priceMutation . isError && (
< div className = "px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300" >
Erreur de pricing — vérifiez les jambes sélectionnées .
< / div >
) }
{ priced && (
< >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-3" >
< div className = "card-sm" >
< div className = "stat-label" > Coût d 'entrée (spread inclus)</div>
<div className={clsx(' text - lg font - bold ', priced.entry_cost >= 0 ? ' text - white ' : ' text - emerald - 400 ')}>{fmtMoney(priced.entry_cost)}</div>
</div>
<div className="card-sm">
<div className="stat-label">P&L net scénario J+{horizonDays}</div>
<div className={clsx(' text - lg font - bold ', pnlColor(priced.net_pnl))}>{fmtMoney(priced.net_pnl)}</div>
</div>
<div className="card-sm">
<div className="stat-label">Coût spread broker</div>
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
</div>
<div className="card-sm">
<div className="stat-label">Max gain / Max perte</div>
<div className="text-sm font-semibold">
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : ' ∞ '}</span>
<span className="text-slate-500"> / </span>
<span className="text-red-400">{priced.max_loss != null ? fmtMoney(priced.max_loss) : ' − ∞ '}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={clsx(' badge ', priced.bounded_risk ? ' badge - green ' : ' badge - red ')}>
{priced.bounded_risk ? ' Risque borné ' : ' Risque non borné '}
</span>
<span className={clsx(' badge ', isNonDirectional ? ' badge - blue ' : ' badge - orange ')}>
Δ net {priced.net_delta_now.toFixed(3)} — {isNonDirectional ? ' non - directionnel ' : ' directionnel '}
</span>
<button
onClick={handleSaveStrategy}
disabled={saveStrategy.isPending}
className="ml-auto flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2.5 py-1 rounded disabled:opacity-40"
>
<Save className="w-3 h-3" /> {saveStrategy.isSuccess ? ' Sauvegardé ✓ ' : ' Sauvegarder cette stratégie ' }
< / button >
< / div >
< div className = "card" >
< div className = "stat-label mb-2" > Diagramme payoff < / div >
< PayoffChart priced = { priced } spot = { priced . spot } scenarioSpot = { priced . scenario_spot } / >
< / div >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-3" >
< GreeksTile label = "Delta net" now = { priced . greeks_now . delta } scenario = { priced . greeks_scenario . delta } / >
< GreeksTile label = "Gamma net" now = { priced . greeks_now . gamma } scenario = { priced . greeks_scenario . gamma } / >
< GreeksTile label = "Theta net" now = { priced . greeks_now . theta } scenario = { priced . greeks_scenario . theta } / >
< GreeksTile label = "Vega net" now = { priced . greeks_now . vega } scenario = { priced . greeks_scenario . vega } / >
< / div >
< / >
) }
< / div >
)
}