fix: aligner les candles du chart avec la période du backtest
Bug: les markers BUY/SELL du chart utilisaient les timestamps des trades du backtest mais les candles étaient fetchées séparément (500 candles récentes), causant un désalignement visuel. - Backend: /backtest retourne désormais les candles exactes du DataFrame analysé - Frontend: Backtest.tsx utilise result.candles directement (suppression du fetchCandles séparé) - Ajout: sérialisation reason/OB/LL sur les trades, overlays OB/LL bornés dans le temps, trade reasons expandables, filtre HTF, badge tendance HTF Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,11 @@ const INSTRUMENTS = [
|
||||
'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD',
|
||||
]
|
||||
|
||||
const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D']
|
||||
const GRANULARITIES = ['M1', 'M5', 'M15', 'M30', 'H1', 'H4', 'D']
|
||||
|
||||
const HTF_MAP: Record<string, string> = {
|
||||
M1: 'M5', M5: 'M15', M15: 'H1', M30: 'H1', H1: 'H4', H4: 'D', D: 'D',
|
||||
}
|
||||
|
||||
export default function BacktestForm({ onResult }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -30,16 +34,38 @@ export default function BacktestForm({ onResult }: Props) {
|
||||
spread_pips: 1.5,
|
||||
})
|
||||
|
||||
const [strategies, setStrategies] = useState<string[]>(['order_block_sweep'])
|
||||
const [filters, setFilters] = useState<string[]>([])
|
||||
const [htfEmaFast, setHtfEmaFast] = useState(20)
|
||||
const [htfEmaSlow, setHtfEmaSlow] = useState(50)
|
||||
|
||||
const handleChange = (key: string, value: string | number) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const toggleStrategy = (name: string) => {
|
||||
setStrategies((prev) =>
|
||||
prev.includes(name) ? prev.filter((s) => s !== name) : [...prev, name]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleFilter = (name: string) => {
|
||||
setFilters((prev) =>
|
||||
prev.includes(name) ? prev.filter((f) => f !== name) : [...prev, name]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await runBacktest(form)
|
||||
const result = await runBacktest({
|
||||
...form,
|
||||
strategies,
|
||||
filters,
|
||||
...(filters.includes('htf_trend_filter') ? { htf_ema_fast: htfEmaFast, htf_ema_slow: htfEmaSlow } : {}),
|
||||
})
|
||||
onResult(result)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
@@ -50,9 +76,10 @@ export default function BacktestForm({ onResult }: Props) {
|
||||
}
|
||||
|
||||
const Field = ({
|
||||
label, name, type = 'number', options
|
||||
label, name, type = 'number', options, value, onChange,
|
||||
}: {
|
||||
label: string; name: string; type?: string; options?: string[]
|
||||
value?: number; onChange?: (v: number) => void
|
||||
}) => (
|
||||
<div>
|
||||
<label className="block text-xs text-[#64748b] mb-1">{label}</label>
|
||||
@@ -68,18 +95,41 @@ export default function BacktestForm({ onResult }: Props) {
|
||||
<input
|
||||
type={type}
|
||||
step="any"
|
||||
value={form[name as keyof typeof form]}
|
||||
onChange={(e) => handleChange(name, parseFloat(e.target.value) || 0)}
|
||||
value={value ?? form[name as keyof typeof form]}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value) || 0
|
||||
if (onChange) onChange(v)
|
||||
else handleChange(name, v)
|
||||
}}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Checkbox = ({
|
||||
label, checked, onChange, description,
|
||||
}: {
|
||||
label: string; checked: boolean; onChange: () => void; description?: string
|
||||
}) => (
|
||||
<label className="flex items-start gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="mt-0.5 accent-[#6366f1] w-3.5 h-3.5"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm text-white group-hover:text-[#6366f1] transition-colors">{label}</span>
|
||||
{description && <p className="text-[10px] text-[#64748b] mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider mb-4">
|
||||
Paramètres du backtest
|
||||
Parametres du backtest
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -90,10 +140,55 @@ export default function BacktestForm({ onResult }: Props) {
|
||||
<Field label="Risque par trade (%)" name="risk_percent" />
|
||||
<Field label="Ratio R:R" name="rr_ratio" />
|
||||
<Field label="Swing strength" name="swing_strength" />
|
||||
<Field label="Tolérance liquidité (pips)" name="liquidity_tolerance_pips" />
|
||||
<Field label="Tolerance liquidite (pips)" name="liquidity_tolerance_pips" />
|
||||
<Field label="Spread (pips)" name="spread_pips" />
|
||||
</div>
|
||||
|
||||
{/* Limite données info */}
|
||||
{(form.granularity === 'M1' || form.granularity === 'M5') && (
|
||||
<div className="text-[10px] text-[#64748b] bg-[#0f1117] rounded-lg px-3 py-2 border border-[#2a2d3e]">
|
||||
{form.granularity === 'M1' ? 'M1 : donnees limitees a 7 jours' : 'M5 : donnees limitees a 60 jours'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stratégies */}
|
||||
<div className="border-t border-[#2a2d3e] pt-3">
|
||||
<h3 className="text-[10px] text-[#64748b] uppercase tracking-wider mb-2">Strategies</h3>
|
||||
<Checkbox
|
||||
label="Order Block + Liquidity Sweep"
|
||||
description="ICT : Equal H/L, sweep et entree sur OB"
|
||||
checked={strategies.includes('order_block_sweep')}
|
||||
onChange={() => toggleStrategy('order_block_sweep')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<div className="border-t border-[#2a2d3e] pt-3">
|
||||
<h3 className="text-[10px] text-[#64748b] uppercase tracking-wider mb-2">Filtres</h3>
|
||||
<Checkbox
|
||||
label="Filtre tendance HTF"
|
||||
description={`Trades uniquement dans le sens de la tendance ${HTF_MAP[form.granularity] ?? ''} (EMA cross)`}
|
||||
checked={filters.includes('htf_trend_filter')}
|
||||
onChange={() => toggleFilter('htf_trend_filter')}
|
||||
/>
|
||||
{filters.includes('htf_trend_filter') && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-2 ml-5">
|
||||
<Field
|
||||
label="EMA rapide"
|
||||
name="htf_ema_fast"
|
||||
value={htfEmaFast}
|
||||
onChange={setHtfEmaFast}
|
||||
/>
|
||||
<Field
|
||||
label="EMA lente"
|
||||
name="htf_ema_slow"
|
||||
value={htfEmaSlow}
|
||||
onChange={setHtfEmaSlow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2">
|
||||
{error}
|
||||
@@ -102,7 +197,7 @@ export default function BacktestForm({ onResult }: Props) {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || strategies.length === 0}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 bg-[#6366f1] hover:bg-[#4f46e5] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { BacktestResult } from '../../lib/api'
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ function MetricCard({ label, value, color }: { label: string; value: string; col
|
||||
export default function BacktestResults({ result }: Props) {
|
||||
const { metrics, equity_curve, trades } = result
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const [expandedRow, setExpandedRow] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current || equity_curve.length === 0) return
|
||||
@@ -115,44 +117,95 @@ export default function BacktestResults({ result }: Props) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((t, i) => (
|
||||
<tr key={i} className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20]">
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded font-semibold ${
|
||||
t.direction === 'buy'
|
||||
? 'bg-[#26a69a20] text-[#26a69a]'
|
||||
: 'bg-[#ef535020] text-[#ef5350]'
|
||||
<React.Fragment key={i}>
|
||||
<tr
|
||||
className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20] cursor-pointer"
|
||||
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded font-semibold ${
|
||||
t.direction === 'buy'
|
||||
? 'bg-[#26a69a20] text-[#26a69a]'
|
||||
: 'bg-[#ef535020] text-[#ef5350]'
|
||||
}`}>
|
||||
{t.direction.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}>
|
||||
<span className="flex items-center gap-1">
|
||||
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')}
|
||||
{t.reason && (
|
||||
expandedRow === i
|
||||
? <ChevronUp size={12} className="text-[#6366f1] shrink-0" />
|
||||
: <ChevronDown size={12} className="text-[#64748b] shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{t.entry_price.toFixed(5)}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-[#64748b]">
|
||||
{t.exit_price?.toFixed(5) ?? '—'}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-mono ${
|
||||
(t.pnl_pips ?? 0) > 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'
|
||||
}`}>
|
||||
{t.direction.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}>
|
||||
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono">{t.entry_price.toFixed(5)}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-[#64748b]">
|
||||
{t.exit_price?.toFixed(5) ?? '—'}
|
||||
</td>
|
||||
<td className={`px-3 py-2 text-right font-mono ${
|
||||
(t.pnl_pips ?? 0) > 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'
|
||||
}`}>
|
||||
{t.pnl_pips !== null
|
||||
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
|
||||
{t.status === 'win' ? '✓ Win' : '✗ Loss'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{t.exit_time
|
||||
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{t.pnl_pips !== null
|
||||
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
|
||||
{t.status === 'win' ? '✓ Win' : '✗ Loss'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{t.exit_time
|
||||
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedRow === i && t.reason && (
|
||||
<tr className="bg-[#0f1117]">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<div className="text-[11px] space-y-1.5">
|
||||
<div className="text-white font-medium">{t.reason.summary}</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[#94a3b8]">
|
||||
{t.reason.swept_level_price != null && (
|
||||
<span>
|
||||
Liquidite : <span className={t.reason.swept_level_direction === 'high' ? 'text-[#ef5350]' : 'text-[#26a69a]'}>
|
||||
{t.reason.swept_level_direction === 'high' ? 'Equal High' : 'Equal Low'} @ {t.reason.swept_level_price.toFixed(5)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{t.reason.ob_direction && (
|
||||
<span>
|
||||
Order Block : <span className={t.reason.ob_direction === 'bullish' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
|
||||
{t.reason.ob_direction === 'bullish' ? 'Bullish' : 'Bearish'} [{t.reason.ob_bottom?.toFixed(5)} - {t.reason.ob_top?.toFixed(5)}]
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{t.reason.ob_origin_time && (
|
||||
<span>
|
||||
OB forme le : {new Date(t.reason.ob_origin_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{t.reason.filters_applied.length > 0 && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
{t.reason.filters_applied.map((f, fi) => (
|
||||
<span key={fi} className="px-2 py-0.5 rounded bg-[#6366f120] text-[#6366f1] border border-[#6366f133] text-[10px]">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
AreaSeries,
|
||||
LineSeries,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
@@ -8,29 +10,15 @@ import {
|
||||
type SeriesMarker,
|
||||
type Time,
|
||||
ColorType,
|
||||
LineStyle,
|
||||
} from 'lightweight-charts'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Candle, BacktestTrade, Trade } from '../../lib/api'
|
||||
|
||||
interface OrderBlock {
|
||||
direction: 'bullish' | 'bearish'
|
||||
top: number
|
||||
bottom: number
|
||||
origin_time: string
|
||||
mitigated: boolean
|
||||
}
|
||||
|
||||
interface LiquidityLevel {
|
||||
direction: 'high' | 'low'
|
||||
price: number
|
||||
origin_time: string
|
||||
swept: boolean
|
||||
}
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Candle, BacktestTrade, Trade, OrderBlockData, LiquidityLevelData } from '../../lib/api'
|
||||
|
||||
interface Props {
|
||||
candles: Candle[]
|
||||
orderBlocks?: OrderBlock[]
|
||||
liquidityLevels?: LiquidityLevel[]
|
||||
orderBlocks?: OrderBlockData[]
|
||||
liquidityLevels?: LiquidityLevelData[]
|
||||
trades?: (BacktestTrade | Trade)[]
|
||||
height?: number
|
||||
}
|
||||
@@ -62,13 +50,53 @@ function fmt(v: number): string {
|
||||
export default function CandlestickChart({
|
||||
candles,
|
||||
orderBlocks = [],
|
||||
liquidityLevels = [],
|
||||
trades = [],
|
||||
height = 500,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
const overlaySeriesRef = useRef<(ISeriesApi<'Area'> | ISeriesApi<'Line'>)[]>([])
|
||||
const [legend, setLegend] = useState<OhlcLegend | null>(null)
|
||||
const [hoverPrice, setHoverPrice] = useState<number | null>(null)
|
||||
|
||||
// Annotation la plus proche du curseur (OB ou LL)
|
||||
const hoverAnnotation = useMemo(() => {
|
||||
if (hoverPrice === null || (!orderBlocks.length && !liquidityLevels.length)) return null
|
||||
const pip = hoverPrice >= 100 ? 0.01 : hoverPrice >= 10 ? 0.001 : 0.0001
|
||||
const threshold = pip * 15
|
||||
|
||||
let best: { label: string; detail: string; color: string } | null = null
|
||||
let minDist = threshold
|
||||
|
||||
for (const ob of orderBlocks) {
|
||||
const mid = (ob.top + ob.bottom) / 2
|
||||
const dist = Math.abs(hoverPrice - mid)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
best = {
|
||||
label: ob.direction === 'bullish' ? 'Bullish OB' : 'Bearish OB',
|
||||
detail: `${ob.bottom.toFixed(5)} – ${ob.top.toFixed(5)}`,
|
||||
color: ob.direction === 'bullish' ? '#26a69a' : '#ef5350',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ll of liquidityLevels) {
|
||||
const dist = Math.abs(hoverPrice - ll.price)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
best = {
|
||||
label: ll.direction === 'high' ? 'EQH' : 'EQL',
|
||||
detail: ll.price.toFixed(5),
|
||||
color: ll.direction === 'high' ? '#ef5350' : '#26a69a',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}, [hoverPrice, orderBlocks, liquidityLevels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
@@ -111,6 +139,7 @@ export default function CandlestickChart({
|
||||
chart.subscribeCrosshairMove((param) => {
|
||||
if (!param.seriesData.size) {
|
||||
setLegend(null)
|
||||
setHoverPrice(null)
|
||||
return
|
||||
}
|
||||
const d = param.seriesData.get(candleSeries) as CandlestickData | undefined
|
||||
@@ -123,6 +152,9 @@ export default function CandlestickChart({
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
})
|
||||
if (param.point) {
|
||||
setHoverPrice(candleSeries.coordinateToPrice(param.point.y))
|
||||
}
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -195,6 +227,100 @@ export default function CandlestickChart({
|
||||
chartRef.current?.timeScale().fitContent()
|
||||
}, [candles, trades])
|
||||
|
||||
// Overlay: Order Blocks (zones bornées) + Liquidity Levels (lignes bornées dans le temps)
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current
|
||||
const candleSeries = candleSeriesRef.current
|
||||
if (!chart || !candleSeries) return
|
||||
|
||||
// Cleanup toujours en premier, même quand candles est vide
|
||||
for (const s of overlaySeriesRef.current) {
|
||||
try { chart.removeSeries(s) } catch { /* already removed */ }
|
||||
}
|
||||
overlaySeriesRef.current = []
|
||||
|
||||
if (candles.length === 0) return
|
||||
|
||||
const lastTime = toTimestamp(candles[candles.length - 1].time)
|
||||
|
||||
// Maps ob_id / ll_id → temps de fin (depuis les trades)
|
||||
const obEndTimes = new Map<string, Time>()
|
||||
const llEndTimes = new Map<string, Time>()
|
||||
for (const trade of trades) {
|
||||
if ('order_block' in trade) {
|
||||
const bt = trade as BacktestTrade
|
||||
if (bt.order_block?.id && bt.exit_time) {
|
||||
obEndTimes.set(bt.order_block.id, toTimestamp(bt.exit_time))
|
||||
}
|
||||
if (bt.liquidity_level?.id && bt.entry_time) {
|
||||
llEndTimes.set(bt.liquidity_level.id, toTimestamp(bt.entry_time))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Order Blocks — uniquement ceux utilisés dans un trade, de origin_time à exit_time
|
||||
const sortedObs = [...orderBlocks]
|
||||
.filter((ob) => obEndTimes.has(ob.id))
|
||||
.sort((a, b) => (a.mitigated === b.mitigated ? 0 : a.mitigated ? 1 : -1))
|
||||
|
||||
for (const ob of sortedObs) {
|
||||
const alpha = 0.14
|
||||
const isBullish = ob.direction === 'bullish'
|
||||
const color = isBullish ? `rgba(38, 166, 154, ${alpha})` : `rgba(239, 83, 80, ${alpha})`
|
||||
const lineColor = isBullish ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)'
|
||||
|
||||
try {
|
||||
const startTime = toTimestamp(ob.origin_time)
|
||||
const endTime = obEndTimes.get(ob.id) ?? lastTime
|
||||
if ((startTime as number) >= (endTime as number)) continue
|
||||
|
||||
const areaSeries = chart.addSeries(AreaSeries, {
|
||||
topColor: color,
|
||||
bottomColor: 'transparent',
|
||||
lineColor,
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
crosshairMarkerVisible: false,
|
||||
priceScaleId: 'right',
|
||||
baseValue: { type: 'price' as const, price: ob.bottom },
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
})
|
||||
areaSeries.setData([
|
||||
{ time: startTime, value: ob.top },
|
||||
{ time: endTime, value: ob.top },
|
||||
])
|
||||
overlaySeriesRef.current.push(areaSeries)
|
||||
} catch { /* skip si problème de temps */ }
|
||||
}
|
||||
|
||||
// Liquidity Levels — uniquement ceux sweepés pour déclencher un trade, de origin_time à entry_time
|
||||
for (const level of liquidityLevels.filter((l) => llEndTimes.has(l.id))) {
|
||||
try {
|
||||
const startTime = toTimestamp(level.origin_time)
|
||||
const endTime = llEndTimes.get(level.id)!
|
||||
if ((startTime as number) >= (endTime as number)) continue
|
||||
|
||||
const isHigh = level.direction === 'high'
|
||||
const color = isHigh ? 'rgba(239, 83, 80, 0.7)' : 'rgba(38, 166, 154, 0.7)'
|
||||
|
||||
const lineSeries = chart.addSeries(LineSeries, {
|
||||
color,
|
||||
lineWidth: 1,
|
||||
lineStyle: LineStyle.Dashed,
|
||||
crosshairMarkerVisible: false,
|
||||
lastValueVisible: false,
|
||||
priceLineVisible: false,
|
||||
})
|
||||
lineSeries.setData([
|
||||
{ time: startTime, value: level.price },
|
||||
{ time: endTime, value: level.price },
|
||||
])
|
||||
overlaySeriesRef.current.push(lineSeries)
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, [candles, orderBlocks, liquidityLevels, trades])
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]">
|
||||
{/* Légende OHLC */}
|
||||
@@ -206,17 +332,34 @@ export default function CandlestickChart({
|
||||
<span className="text-slate-300">H <span className="text-[#26a69a]">{fmt(legend.high)}</span></span>
|
||||
<span className="text-slate-300">L <span className="text-[#ef5350]">{fmt(legend.low)}</span></span>
|
||||
<span className="text-slate-300">C <span className="text-white">{fmt(legend.close)}</span></span>
|
||||
{hoverAnnotation && (
|
||||
<span
|
||||
className="ml-1 px-2 py-0.5 rounded border text-xs font-mono"
|
||||
style={{
|
||||
color: hoverAnnotation.color,
|
||||
borderColor: hoverAnnotation.color + '55',
|
||||
backgroundColor: hoverAnnotation.color + '18',
|
||||
}}
|
||||
>
|
||||
{hoverAnnotation.label}: {hoverAnnotation.detail}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{orderBlocks.filter((o) => o.direction === 'bullish').length > 0 && (
|
||||
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bullish').length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded bg-[#26a69a33] text-[#26a69a] border border-[#26a69a55]">
|
||||
{orderBlocks.filter((o) => o.direction === 'bullish').length} Bullish OB
|
||||
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bullish').length} Bullish OB
|
||||
</span>
|
||||
)}
|
||||
{orderBlocks.filter((o) => o.direction === 'bearish').length > 0 && (
|
||||
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bearish').length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded bg-[#ef535033] text-[#ef5350] border border-[#ef535055]">
|
||||
{orderBlocks.filter((o) => o.direction === 'bearish').length} Bearish OB
|
||||
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bearish').length} Bearish OB
|
||||
</span>
|
||||
)}
|
||||
{trades.filter((t) => 'liquidity_level' in t && (t as BacktestTrade).liquidity_level).length > 0 && (
|
||||
<span className="px-2 py-0.5 rounded bg-[#6366f133] text-[#6366f1] border border-[#6366f155]">
|
||||
{trades.filter((t) => 'liquidity_level' in t && (t as BacktestTrade).liquidity_level).length} Sweeps
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,34 @@ export interface BacktestMetrics {
|
||||
profit_factor: number
|
||||
}
|
||||
|
||||
export interface TradeReason {
|
||||
summary: string
|
||||
swept_level_price: number | null
|
||||
swept_level_direction: string | null
|
||||
ob_direction: string | null
|
||||
ob_top: number | null
|
||||
ob_bottom: number | null
|
||||
ob_origin_time: string | null
|
||||
filters_applied: string[]
|
||||
}
|
||||
|
||||
export interface OrderBlockData {
|
||||
id: string
|
||||
direction: 'bullish' | 'bearish'
|
||||
top: number
|
||||
bottom: number
|
||||
origin_time: string
|
||||
mitigated: boolean
|
||||
}
|
||||
|
||||
export interface LiquidityLevelData {
|
||||
id: string
|
||||
direction: 'high' | 'low'
|
||||
price: number
|
||||
origin_time: string
|
||||
swept: boolean
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
backtest_id: number
|
||||
instrument: string
|
||||
@@ -57,6 +85,10 @@ export interface BacktestResult {
|
||||
metrics: BacktestMetrics
|
||||
equity_curve: { time: string; balance: number }[]
|
||||
trades: BacktestTrade[]
|
||||
candles: Candle[]
|
||||
order_blocks: OrderBlockData[]
|
||||
liquidity_levels: LiquidityLevelData[]
|
||||
htf_trend?: { htf_granularity: string; trend: string; ema_fast: number; ema_slow: number } | null
|
||||
}
|
||||
|
||||
export interface BacktestTrade {
|
||||
@@ -70,6 +102,9 @@ export interface BacktestTrade {
|
||||
pnl_pips: number | null
|
||||
status: string
|
||||
signal_type: string
|
||||
reason: TradeReason | null
|
||||
order_block: OrderBlockData | null
|
||||
liquidity_level: LiquidityLevelData | null
|
||||
}
|
||||
|
||||
export interface BotStatus {
|
||||
@@ -124,6 +159,10 @@ export const runBacktest = (params: {
|
||||
swing_strength: number
|
||||
liquidity_tolerance_pips: number
|
||||
spread_pips: number
|
||||
strategies: string[]
|
||||
filters: string[]
|
||||
htf_ema_fast?: number
|
||||
htf_ema_slow?: number
|
||||
}) =>
|
||||
api.post<BacktestResult>('/backtest', params).then((r) => r.data)
|
||||
|
||||
|
||||
@@ -2,18 +2,15 @@ import { useState } from 'react'
|
||||
import BacktestForm from '../components/Backtest/BacktestForm'
|
||||
import BacktestResults from '../components/Backtest/BacktestResults'
|
||||
import CandlestickChart from '../components/Chart/CandlestickChart'
|
||||
import type { BacktestResult, Candle } from '../lib/api'
|
||||
import { fetchCandles } from '../lib/api'
|
||||
import type { BacktestResult } from '../lib/api'
|
||||
|
||||
export default function Backtest() {
|
||||
const [result, setResult] = useState<BacktestResult | null>(null)
|
||||
const [candles, setCandles] = useState<Candle[]>([])
|
||||
const [backtestKey, setBacktestKey] = useState(0)
|
||||
|
||||
const handleResult = async (r: BacktestResult) => {
|
||||
const handleResult = (r: BacktestResult) => {
|
||||
setResult(r)
|
||||
// Charger les candles pour la période du backtest
|
||||
const c = await fetchCandles(r.instrument, r.granularity, 500)
|
||||
setCandles(c)
|
||||
setBacktestKey(k => k + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -25,14 +22,30 @@ export default function Backtest() {
|
||||
|
||||
{/* Right: results */}
|
||||
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-auto">
|
||||
<h1 className="text-base font-bold text-white">Backtesting — Order Block + Liquidity Sweep</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-base font-bold text-white">
|
||||
Backtesting{result ? ` — ${result.instrument} ${result.granularity}` : ''}
|
||||
</h1>
|
||||
{result?.htf_trend && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${
|
||||
result.htf_trend.trend === 'bullish'
|
||||
? 'bg-[#26a69a20] text-[#26a69a] border-[#26a69a55]'
|
||||
: 'bg-[#ef535020] text-[#ef5350] border-[#ef535055]'
|
||||
}`}>
|
||||
HTF {result.htf_trend.htf_granularity}: {result.htf_trend.trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<>
|
||||
{/* Chart avec les trades superposés */}
|
||||
{/* Chart avec trades, Order Blocks et niveaux de liquidité */}
|
||||
<CandlestickChart
|
||||
candles={candles}
|
||||
key={backtestKey}
|
||||
candles={result.candles}
|
||||
trades={result.trades}
|
||||
orderBlocks={result.order_blocks}
|
||||
liquidityLevels={result.liquidity_levels}
|
||||
height={360}
|
||||
/>
|
||||
<BacktestResults result={result} />
|
||||
|
||||
Reference in New Issue
Block a user