feat: builder strategy

This commit is contained in:
OpenSquared
2026-07-19 11:08:17 +02:00
parent 67952e01fa
commit 8eb9cc1c05
2 changed files with 119 additions and 18 deletions

View File

@@ -167,6 +167,21 @@ def _compute_indicators(df: pd.DataFrame, config: Dict) -> Dict[str, Any]:
result["bb_lower"] = []
result["bb_mid"] = []
# Realized volatility (annualized %, rolling 20d stddev of log returns) — a "how
# turbulent has price action actually been" overlay, distinct from the market-implied
# vol computed elsewhere (vol_surface.py) for the options Strategy Builder.
vol_window = chart_cfg.get("volatility_window", 20)
if len(close) >= vol_window + 1:
log_ret = np.log(close / close.shift(1))
realized_vol = log_ret.rolling(vol_window).std() * np.sqrt(252) * 100
valid_vol = realized_vol.dropna()
result["volatility"] = [
{"time": idx.strftime("%Y-%m-%d"), "value": _round(val, 2)}
for idx, val in valid_vol.items()
]
else:
result["volatility"] = []
# RSI 14
if len(close) >= 15:
delta = close.diff()

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react'
import { useRef, useEffect, useState } from 'react'
interface PriceCandle {
time: string
@@ -51,6 +51,20 @@ const MA_COLORS: Record<string, string> = {
ma200: '#a855f7',
}
// Every overlay a user can show/hide independently. 'bb' toggles the upper+lower band pair
// together (they're one visual concept); 'volatility' gets its own price scale since
// annualized % lives on a totally different axis than the price series.
type OverlayKey = 'ma20' | 'ma50' | 'ma100' | 'ma200' | 'bb' | 'volatility'
const OVERLAY_KEYS: OverlayKey[] = ['ma20', 'ma50', 'ma100', 'ma200', 'bb', 'volatility']
const OVERLAY_META: Record<OverlayKey, { label: string; color: string; dashed?: boolean; defaultVisible: boolean }> = {
ma20: { label: 'MA20', color: MA_COLORS.ma20, defaultVisible: true },
ma50: { label: 'MA50', color: MA_COLORS.ma50, defaultVisible: true },
ma100: { label: 'MA100', color: MA_COLORS.ma100, defaultVisible: true },
ma200: { label: 'MA200', color: MA_COLORS.ma200, defaultVisible: true },
bb: { label: 'BB(20,2)', color: '#94a3b8', dashed: true, defaultVisible: true },
volatility: { label: 'Volatilité', color: '#ec4899', defaultVisible: false },
}
const CAT_COLORS: Record<string, string> = {
event_calendar: '#f59e0b',
geopolitical: '#ef4444',
@@ -74,6 +88,32 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
const cleanupRef = useRef<(() => void) | null>(null)
const onHoverRef = useRef(onDateHover)
const onChartReadyRef = useRef(onChartReady)
const chartRef = useRef<any>(null)
const seriesRefs = useRef<Partial<Record<OverlayKey, any[]>>>({})
// Which overlays have any data at all for THIS instrument — only these get a toggle pill.
const availableOverlays = OVERLAY_KEYS.filter(k =>
k === 'bb' ? (indicators.bb_upper?.length && indicators.bb_lower?.length) : indicators[k]?.length
)
const [visible, setVisible] = useState<Record<OverlayKey, boolean>>(() =>
Object.fromEntries(OVERLAY_KEYS.map(k => [k, OVERLAY_META[k].defaultVisible])) as Record<OverlayKey, boolean>
)
// Read at chart-(re)build time without forcing a rebuild on every toggle — toggling
// itself is handled cheaply below via each series' own applyOptions({visible}).
const visibleRef = useRef(visible)
useEffect(() => { visibleRef.current = visible }, [visible])
const toggleOverlay = (key: OverlayKey) => {
setVisible(v => {
const next = { ...v, [key]: !v[key] }
seriesRefs.current[key]?.forEach(s => s.applyOptions({ visible: next[key] }))
if (key === 'volatility') {
chartRef.current?.priceScale('volatility').applyOptions({ visible: next[key] })
}
return next
})
}
// Keep callback refs fresh without triggering chart rebuild
useEffect(() => { onHoverRef.current = onDateHover }, [onDateHover])
@@ -116,6 +156,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
handleScroll: true,
handleScale: true,
})
chartRef.current = chart
seriesRefs.current = {}
// Volume (bottom 18%)
const totalVol = priceData.reduce((s, d) => s + (d.volume || 0), 0)
@@ -158,21 +200,24 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
candleSeries.setData(priceData)
}
// MA lines
// MA lines — each individually toggleable
for (const [key, color] of Object.entries(MA_COLORS)) {
const data = indicators[key]
if (data?.length) {
chart.addLineSeries({
const series = chart.addLineSeries({
color,
lineWidth: key === 'ma200' ? 1.5 : 1,
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: false,
}).setData(data)
visible: visibleRef.current[key as OverlayKey],
})
series.setData(data)
seriesRefs.current[key as OverlayKey] = [series]
}
}
// Bollinger Bands
// Bollinger Bands — upper+lower toggle together as one 'bb' overlay
if (indicators.bb_upper?.length && indicators.bb_lower?.length) {
const bbOpts = {
color: 'rgba(148,163,184,0.32)',
@@ -181,9 +226,35 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
visible: visibleRef.current.bb,
}
chart.addLineSeries(bbOpts).setData(indicators.bb_upper)
chart.addLineSeries(bbOpts).setData(indicators.bb_lower)
const bbUpper = chart.addLineSeries(bbOpts)
bbUpper.setData(indicators.bb_upper)
const bbLower = chart.addLineSeries(bbOpts)
bbLower.setData(indicators.bb_lower)
seriesRefs.current.bb = [bbUpper, bbLower]
}
// Volatilité réalisée (annualisée, %) — échelle dédiée, cachée par défaut
if (indicators.volatility?.length) {
chart.priceScale('volatility').applyOptions({
visible: visibleRef.current.volatility,
borderColor: 'rgba(236,72,153,0.25)',
scaleMargins: { top: 0.78, bottom: 0.02 },
})
const volatilitySeries = chart.addLineSeries({
color: '#ec4899',
lineWidth: 1,
priceScaleId: 'volatility',
title: 'Vol. réalisée %',
priceLineVisible: false,
lastValueVisible: true,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 3,
visible: visibleRef.current.volatility,
})
volatilitySeries.setData(indicators.volatility)
seriesRefs.current.volatility = [volatilitySeries]
}
// ── Theoretical curve — left scale (pips) ─────────────────────────────
@@ -328,6 +399,8 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
chart.timeScale().unsubscribeVisibleLogicalRangeChange(renderStars)
if (starsOverlay.parentNode) starsOverlay.parentNode.removeChild(starsOverlay)
chart.remove()
chartRef.current = null
seriesRefs.current = {}
onChartReadyRef.current?.(null as any, 0)
}
})
@@ -337,22 +410,35 @@ export default function InstrumentChart({ priceData, indicators, events = [], he
cleanupRef.current?.()
cleanupRef.current = null
}
// `visible` intentionally excluded — toggling is handled directly via series refs
// above, without tearing down and rebuilding the whole chart.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [priceData, indicators, events, height, chartType, theoryCurve])
return (
<div className="bg-dark-900/60 rounded-xl border border-slate-700/40 overflow-hidden">
<div className="flex items-center gap-5 px-4 py-2 border-b border-slate-700/30 text-xs text-slate-500">
{Object.entries(MA_COLORS).map(([k, c]) => (
<span key={k} className="flex items-center gap-1.5">
<span className="inline-block w-5 h-0.5 rounded" style={{ background: c }} />
{k.toUpperCase()}
</span>
))}
<span className="flex items-center gap-1.5 opacity-40">
<span className="inline-block w-5 border-t border-dashed border-slate-400" />BB(20,2)
</span>
<div className="flex items-center gap-2 px-4 py-2 border-b border-slate-700/30 text-xs flex-wrap">
{availableOverlays.map(key => {
const meta = OVERLAY_META[key]
const on = visible[key]
return (
<button
key={key}
onClick={() => toggleOverlay(key)}
title={on ? `Masquer ${meta.label}` : `Afficher ${meta.label}`}
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded transition-opacity hover:bg-slate-700/30"
style={{ opacity: on ? 1 : 0.35 }}
>
<span
className={meta.dashed ? 'inline-block w-5 border-t border-dashed' : 'inline-block w-5 h-0.5 rounded'}
style={meta.dashed ? { borderColor: meta.color } : { background: meta.color }}
/>
<span className="text-slate-400">{meta.label}</span>
</button>
)
})}
{theoryCurve?.some(p => p.contributions.length > 0) && (
<span className="flex items-center gap-1.5" style={{ color: '#a78bfa' }}>
<span className="flex items-center gap-1.5 px-1.5" style={{ color: '#a78bfa' }}>
<span className="inline-block w-5 h-0.5 rounded" style={{ background: '#a78bfa' }} />
Théorie (pips)
</span>