feat: builder strategy
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user