From 8eb9cc1c0545a1a23428a418e8bc2c875ca039b0 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 19 Jul 2026 11:08:17 +0200 Subject: [PATCH] feat: builder strategy --- backend/services/instrument_service.py | 15 +++ frontend/src/components/InstrumentChart.tsx | 122 +++++++++++++++++--- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/backend/services/instrument_service.py b/backend/services/instrument_service.py index a85a751..7e9a198 100644 --- a/backend/services/instrument_service.py +++ b/backend/services/instrument_service.py @@ -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() diff --git a/frontend/src/components/InstrumentChart.tsx b/frontend/src/components/InstrumentChart.tsx index 66bb251..d6c36b8 100644 --- a/frontend/src/components/InstrumentChart.tsx +++ b/frontend/src/components/InstrumentChart.tsx @@ -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 = { 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 = { + 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 = { 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(null) + const seriesRefs = useRef>>({}) + + // 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>(() => + Object.fromEntries(OVERLAY_KEYS.map(k => [k, OVERLAY_META[k].defaultVisible])) as Record + ) + // 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 (
-
- {Object.entries(MA_COLORS).map(([k, c]) => ( - - - {k.toUpperCase()} - - ))} - - BB(20,2) - +
+ {availableOverlays.map(key => { + const meta = OVERLAY_META[key] + const on = visible[key] + return ( + + ) + })} {theoryCurve?.some(p => p.contributions.length > 0) && ( - + Théorie (pips)