feat: trading bot MVP — ICT Order Block + Liquidity Sweep strategy

Full-stack trading bot with:
- FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection)
- Backtester engine with rolling window, spread simulation, and performance metrics
- Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache)
- Simulated exchange for paper trading
- React/TypeScript frontend with TradingView lightweight-charts v5
- Live dashboard with candlestick chart, OHLC legend, trade markers
- Backtest page with configurable parameters, equity curve, and trade table
- WebSocket support for real-time updates
- Bot runner with asyncio loop for automated trading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:25:51 +01:00
commit 4df8d53b1a
58 changed files with 7484 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trader Bot — ICT Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3377
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "trader-bot-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

53
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { BrowserRouter, NavLink, Route, Routes } from 'react-router-dom'
import { BarChart2, Bot, FlaskConical } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import Backtest from './pages/Backtest'
export default function App() {
return (
<BrowserRouter>
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
{/* Sidebar */}
<aside className="w-16 flex flex-col items-center gap-4 py-6 bg-[#1a1d27] border-r border-[#2a2d3e]">
<div className="text-[#6366f1] mb-4">
<Bot size={28} />
</div>
<NavLink
to="/"
title="Dashboard"
className={({ isActive }) =>
`p-3 rounded-xl transition-colors ${
isActive
? 'bg-[#6366f1] text-white'
: 'text-[#64748b] hover:text-white hover:bg-[#2a2d3e]'
}`
}
>
<BarChart2 size={20} />
</NavLink>
<NavLink
to="/backtest"
title="Backtest"
className={({ isActive }) =>
`p-3 rounded-xl transition-colors ${
isActive
? 'bg-[#6366f1] text-white'
: 'text-[#64748b] hover:text-white hover:bg-[#2a2d3e]'
}`
}
>
<FlaskConical size={20} />
</NavLink>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/backtest" element={<Backtest />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { FlaskConical, Loader2 } from 'lucide-react'
import type { BacktestResult } from '../../lib/api'
import { runBacktest } from '../../lib/api'
interface Props {
onResult: (result: BacktestResult) => void
}
const INSTRUMENTS = [
'EUR_USD', 'GBP_USD', 'USD_JPY', 'USD_CHF', 'AUD_USD', 'USD_CAD',
'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD',
]
const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D']
export default function BacktestForm({ onResult }: Props) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState({
instrument: 'EUR_USD',
granularity: 'H1',
candle_count: 500,
initial_balance: 10000,
risk_percent: 1.0,
rr_ratio: 2.0,
swing_strength: 5,
liquidity_tolerance_pips: 2.0,
spread_pips: 1.5,
})
const handleChange = (key: string, value: string | number) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const result = await runBacktest(form)
onResult(result)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
setError(msg)
} finally {
setLoading(false)
}
}
const Field = ({
label, name, type = 'number', options
}: {
label: string; name: string; type?: string; options?: string[]
}) => (
<div>
<label className="block text-xs text-[#64748b] mb-1">{label}</label>
{options ? (
<select
value={form[name as keyof typeof form]}
onChange={(e) => handleChange(name, e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input
type={type}
step="any"
value={form[name as keyof typeof form]}
onChange={(e) => handleChange(name, parseFloat(e.target.value) || 0)}
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>
)
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
</h2>
<div className="grid grid-cols-2 gap-3">
<Field label="Instrument" name="instrument" options={INSTRUMENTS} />
<Field label="Timeframe" name="granularity" options={GRANULARITIES} />
<Field label="Nombre de bougies" name="candle_count" />
<Field label="Capital initial ($)" name="initial_balance" />
<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="Spread (pips)" name="spread_pips" />
</div>
{error && (
<div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
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 ? (
<><Loader2 size={16} className="animate-spin" /> Backtesting...</>
) : (
<><FlaskConical size={16} /> Lancer le backtest</>
)}
</button>
</form>
)
}

View File

@@ -0,0 +1,163 @@
import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts'
import { useEffect, useRef } from 'react'
import type { BacktestResult } from '../../lib/api'
interface Props {
result: BacktestResult
}
function MetricCard({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg p-3">
<div className="text-xs text-[#64748b] mb-1">{label}</div>
<div className={`text-lg font-bold ${color ?? 'text-white'}`}>{value}</div>
</div>
)
}
export default function BacktestResults({ result }: Props) {
const { metrics, equity_curve, trades } = result
const chartRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!chartRef.current || equity_curve.length === 0) return
const chart = createChart(chartRef.current, {
width: chartRef.current.clientWidth,
height: 220,
layout: {
background: { type: ColorType.Solid, color: '#0f1117' },
textColor: '#64748b',
},
grid: { vertLines: { color: '#1a1d27' }, horzLines: { color: '#1a1d27' } },
rightPriceScale: { borderColor: '#2a2d3e' },
timeScale: { borderColor: '#2a2d3e', timeVisible: true },
})
const lineSeries = chart.addSeries(LineSeries, {
color: '#6366f1',
lineWidth: 2,
})
lineSeries.setData(
equity_curve.map((p) => ({
time: (new Date(p.time).getTime() / 1000) as Time,
value: p.balance,
}))
)
chart.timeScale().fitContent()
const observer = new ResizeObserver(() => {
if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth })
})
observer.observe(chartRef.current)
return () => {
observer.disconnect()
chart.remove()
}
}, [equity_curve])
const pnlPositive = metrics.total_pnl >= 0
return (
<div className="space-y-4">
{/* Métriques clés */}
<div className="grid grid-cols-3 gap-3">
<MetricCard
label="PnL Total"
value={`${pnlPositive ? '+' : ''}${metrics.total_pnl.toFixed(2)} (${metrics.total_pnl_pct.toFixed(1)}%)`}
color={pnlPositive ? 'text-[#26a69a]' : 'text-[#ef5350]'}
/>
<MetricCard
label="Win Rate"
value={`${metrics.win_rate.toFixed(1)}%`}
color={metrics.win_rate >= 50 ? 'text-[#26a69a]' : 'text-[#ef5350]'}
/>
<MetricCard label="Trades" value={`${metrics.winning_trades}W / ${metrics.losing_trades}L`} />
<MetricCard label="Drawdown max" value={`-${metrics.max_drawdown_pct.toFixed(1)}%`} color="text-[#ef5350]" />
<MetricCard label="Sharpe Ratio" value={metrics.sharpe_ratio.toFixed(3)} />
<MetricCard label="Profit Factor" value={metrics.profit_factor.toFixed(2)} />
<MetricCard label="Expectancy (pips)" value={metrics.expectancy.toFixed(1)} />
<MetricCard label="Avg Win" value={`+${metrics.avg_win_pips.toFixed(1)} pips`} color="text-[#26a69a]" />
<MetricCard label="Avg Loss" value={`-${metrics.avg_loss_pips.toFixed(1)} pips`} color="text-[#ef5350]" />
</div>
{/* Equity Curve */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h3 className="text-xs font-semibold text-[#64748b] uppercase tracking-wider mb-3">
Courbe d'équité
</h3>
<div ref={chartRef} />
</div>
{/* Tableau des trades */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a2d3e] flex items-center justify-between">
<h3 className="text-xs font-semibold text-[#64748b] uppercase tracking-wider">
Trades simulés
</h3>
<span className="text-xs text-[#64748b]">{trades.length} trades</span>
</div>
<div className="overflow-x-auto max-h-64">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-[#1a1d27]">
<tr className="text-[#64748b] border-b border-[#2a2d3e]">
<th className="text-left px-3 py-2">Direction</th>
<th className="text-left px-3 py-2">Signal</th>
<th className="text-right px-3 py-2">Entrée</th>
<th className="text-right px-3 py-2">Sortie</th>
<th className="text-right px-3 py-2">PnL (pips)</th>
<th className="text-left px-3 py-2">Résultat</th>
<th className="text-left px-3 py-2">Ouverture</th>
<th className="text-left px-3 py-2">Fermeture</th>
</tr>
</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]'
}`}>
{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>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,229 @@
import {
createChart,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type SeriesMarker,
type Time,
ColorType,
} 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
}
interface Props {
candles: Candle[]
orderBlocks?: OrderBlock[]
liquidityLevels?: LiquidityLevel[]
trades?: (BacktestTrade | Trade)[]
height?: number
}
interface OhlcLegend {
time: string
open: number
high: number
low: number
close: number
}
function toTimestamp(t: string): Time {
return (new Date(t).getTime() / 1000) as Time
}
function pricePrecision(price: number): { precision: number; minMove: number } {
if (price >= 100) return { precision: 2, minMove: 0.01 }
if (price >= 10) return { precision: 3, minMove: 0.001 }
return { precision: 5, minMove: 0.00001 }
}
function fmt(v: number): string {
if (v >= 100) return v.toFixed(2)
if (v >= 10) return v.toFixed(3)
return v.toFixed(5)
}
export default function CandlestickChart({
candles,
orderBlocks = [],
trades = [],
height = 500,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const [legend, setLegend] = useState<OhlcLegend | null>(null)
useEffect(() => {
if (!containerRef.current) return
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height,
layout: {
background: { type: ColorType.Solid, color: '#0f1117' },
textColor: '#64748b',
},
grid: {
vertLines: { color: '#1a1d27' },
horzLines: { color: '#1a1d27' },
},
crosshair: {
vertLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' },
horzLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' },
},
rightPriceScale: { borderColor: '#2a2d3e' },
timeScale: {
borderColor: '#2a2d3e',
timeVisible: true,
secondsVisible: false,
},
})
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
chartRef.current = chart
candleSeriesRef.current = candleSeries
chart.subscribeCrosshairMove((param) => {
if (!param.seriesData.size) {
setLegend(null)
return
}
const d = param.seriesData.get(candleSeries) as CandlestickData | undefined
if (!d) return
const ts = typeof d.time === 'number' ? d.time * 1000 : 0
setLegend({
time: new Date(ts).toUTCString().slice(5, 22),
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})
})
const resizeObserver = new ResizeObserver(() => {
if (containerRef.current) {
chart.applyOptions({ width: containerRef.current.clientWidth })
}
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
chart.remove()
}
}, [height])
// Mise à jour des données
useEffect(() => {
if (!candleSeriesRef.current) return
if (candles.length === 0) {
candleSeriesRef.current.setData([])
return
}
const { precision, minMove } = pricePrecision(candles[0].close)
candleSeriesRef.current.applyOptions({
priceFormat: { type: 'price', precision, minMove },
})
const data: CandlestickData[] = candles.map((c) => ({
time: toTimestamp(c.time),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}))
candleSeriesRef.current.setData(data)
// Markers pour les trades (v5 API)
const markers: SeriesMarker<Time>[] = []
for (const t of trades) {
const entryTime = toTimestamp('entry_time' in t ? t.entry_time : t.opened_at)
markers.push({
time: entryTime,
position: t.direction === 'buy' ? 'belowBar' : 'aboveBar',
color: t.direction === 'buy' ? '#26a69a' : '#ef5350',
shape: t.direction === 'buy' ? 'arrowUp' : 'arrowDown',
text: t.direction === 'buy' ? 'BUY' : 'SELL',
size: 1,
})
const exitTime = 'exit_time' in t ? t.exit_time : t.closed_at
if (exitTime) {
const won = 'pnl_pips' in t ? (t.pnl_pips ?? 0) > 0 : (t.pnl ?? 0) > 0
markers.push({
time: toTimestamp(exitTime),
position: t.direction === 'buy' ? 'aboveBar' : 'belowBar',
color: won ? '#26a69a' : '#ef5350',
shape: 'circle',
text: won ? '✓' : '✗',
size: 0.8,
})
}
}
if (markers.length > 0) {
markers.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeriesRef.current, markers)
}
chartRef.current?.timeScale().fitContent()
}, [candles, trades])
return (
<div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]">
{/* Légende OHLC */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-3 text-xs font-mono">
{legend ? (
<>
<span className="text-[#64748b]">{legend.time}</span>
<span className="text-slate-300">O <span className="text-white">{fmt(legend.open)}</span></span>
<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>
</>
) : (
<div className="flex gap-2">
{orderBlocks.filter((o) => o.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
</span>
)}
{orderBlocks.filter((o) => o.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
</span>
)}
</div>
)}
</div>
<div ref={containerRef} style={{ height }} />
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { Play, Square, Wifi, WifiOff } from 'lucide-react'
import type { BotStatus } from '../../lib/api'
import { startBot, stopBot } from '../../lib/api'
interface Props {
status: BotStatus | null
wsConnected: boolean
onStatusChange: () => void
}
export default function BotStatusCard({ status, wsConnected, onStatusChange }: Props) {
const [loading, setLoading] = useState(false)
const handleToggle = async () => {
setLoading(true)
try {
if (status?.running) {
await stopBot()
} else {
await startBot()
}
onStatusChange()
} finally {
setLoading(false)
}
}
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider">
Bot Status
</h2>
<div className="flex items-center gap-1.5 text-xs">
{wsConnected ? (
<><Wifi size={12} className="text-[#26a69a]" /><span className="text-[#26a69a]">Live</span></>
) : (
<><WifiOff size={12} className="text-[#64748b]" /><span className="text-[#64748b]">Offline</span></>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${status?.running ? 'bg-[#26a69a] animate-pulse' : 'bg-[#64748b]'}`} />
<span className="text-lg font-bold">
{status?.running ? 'Running' : 'Stopped'}
</span>
</div>
{status?.running && (
<div className="mt-2 text-xs text-[#64748b] space-y-0.5">
<div>{status.instrument} · {status.granularity}</div>
{status.started_at && (
<div>Depuis {new Date(status.started_at).toLocaleTimeString('fr-FR')}</div>
)}
</div>
)}
<button
onClick={handleToggle}
disabled={loading}
className={`mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-medium transition-colors ${
status?.running
? 'bg-[#ef535020] text-[#ef5350] hover:bg-[#ef535040] border border-[#ef535033]'
: 'bg-[#6366f1] text-white hover:bg-[#4f46e5]'
} disabled:opacity-50`}
>
{status?.running ? (
<><Square size={14} /> Arrêter</>
) : (
<><Play size={14} /> Démarrer</>
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import type { Trade } from '../../lib/api'
interface Props {
trades: Trade[]
}
export default function PnLSummary({ trades }: Props) {
const closed = trades.filter((t) => t.status === 'closed' && t.pnl !== null)
const totalPnl = closed.reduce((acc, t) => acc + (t.pnl ?? 0), 0)
const winners = closed.filter((t) => (t.pnl ?? 0) > 0)
const winRate = closed.length > 0 ? (winners.length / closed.length) * 100 : 0
const stats = [
{
label: 'PnL Total',
value: `${totalPnl >= 0 ? '+' : ''}${totalPnl.toFixed(2)}`,
color: totalPnl >= 0 ? 'text-[#26a69a]' : 'text-[#ef5350]',
},
{
label: 'Win Rate',
value: `${winRate.toFixed(1)}%`,
color: winRate >= 50 ? 'text-[#26a69a]' : 'text-[#ef5350]',
},
{
label: 'Trades',
value: closed.length.toString(),
color: 'text-white',
},
{
label: 'Ouverts',
value: trades.filter((t) => t.status === 'open').length.toString(),
color: 'text-[#6366f1]',
},
]
return (
<div className="grid grid-cols-2 gap-3">
{stats.map((s) => (
<div key={s.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<div className="text-xs text-[#64748b] mb-1">{s.label}</div>
<div className={`text-xl font-bold ${s.color}`}>{s.value}</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,92 @@
import type { Trade } from '../../lib/api'
interface Props {
trades: Trade[]
}
export default function TradeList({ trades }: Props) {
if (trades.length === 0) {
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 text-center text-[#64748b] text-sm">
Aucun trade pour le moment
</div>
)
}
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a2d3e]">
<h3 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider">
Trades récents
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[#64748b] border-b border-[#2a2d3e]">
<th className="text-left px-4 py-2">Instrument</th>
<th className="text-left px-4 py-2">Direction</th>
<th className="text-right px-4 py-2">Entrée</th>
<th className="text-right px-4 py-2">Sortie</th>
<th className="text-right px-4 py-2">PnL</th>
<th className="text-left px-4 py-2">Statut</th>
<th className="text-left px-4 py-2">Date</th>
</tr>
</thead>
<tbody>
{trades.map((t) => (
<tr
key={t.id}
className="border-b border-[#2a2d3e] hover:bg-[#2a2d3e20] transition-colors"
>
<td className="px-4 py-2.5 font-mono text-xs">{t.instrument}</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-semibold ${
t.direction === 'buy'
? 'bg-[#26a69a20] text-[#26a69a]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}
>
{t.direction.toUpperCase()}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{t.entry_price.toFixed(5)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs text-[#64748b]">
{t.exit_price ? t.exit_price.toFixed(5) : '—'}
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{t.pnl !== null ? (
<span className={t.pnl >= 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
{t.pnl >= 0 ? '+' : ''}{t.pnl.toFixed(2)}
</span>
) : (
<span className="text-[#64748b]"></span>
)}
</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs ${
t.status === 'open'
? 'bg-[#6366f120] text-[#6366f1]'
: t.status === 'closed'
? 'bg-[#2a2d3e] text-[#64748b]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}
>
{t.status}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-[#64748b]">
{new Date(t.opened_at).toLocaleDateString('fr-FR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react'
export interface WsEvent {
type: string
[key: string]: unknown
}
export function useWebSocket(url: string) {
const [lastEvent, setLastEvent] = useState<WsEvent | null>(null)
const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const connect = () => {
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setConnected(true)
pingRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('ping')
}, 30_000)
}
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as WsEvent
setLastEvent(data)
} catch {
// ignore
}
}
ws.onclose = () => {
setConnected(false)
if (pingRef.current) clearInterval(pingRef.current)
// Reconnect après 3s
setTimeout(connect, 3_000)
}
ws.onerror = () => {
ws.close()
}
}
connect()
return () => {
if (pingRef.current) clearInterval(pingRef.current)
wsRef.current?.close()
}
}, [url])
return { lastEvent, connected }
}

33
frontend/src/index.css Normal file
View File

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Inter', system-ui, sans-serif;
}
body {
background-color: #0f1117;
color: #e2e8f0;
margin: 0;
}
* {
box-sizing: border-box;
}
/* Scrollbar dark */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1d27;
}
::-webkit-scrollbar-thumb {
background: #2a2d3e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #6366f1;
}

130
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,130 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30_000,
})
export interface Candle {
time: string
open: number
high: number
low: number
close: number
volume: number
}
export interface Trade {
id: number
source: string
instrument: string
direction: 'buy' | 'sell'
units: number
entry_price: number
stop_loss: number
take_profit: number
exit_price: number | null
pnl: number | null
status: string
signal_type: string | null
opened_at: string
closed_at: string | null
}
export interface BacktestMetrics {
initial_balance: number
final_balance: number
total_pnl: number
total_pnl_pct: number
total_trades: number
winning_trades: number
losing_trades: number
win_rate: number
avg_win_pips: number
avg_loss_pips: number
expectancy: number
max_drawdown: number
max_drawdown_pct: number
sharpe_ratio: number
profit_factor: number
}
export interface BacktestResult {
backtest_id: number
instrument: string
granularity: string
period: { start: string; end: string }
metrics: BacktestMetrics
equity_curve: { time: string; balance: number }[]
trades: BacktestTrade[]
}
export interface BacktestTrade {
direction: string
entry_price: number
exit_price: number | null
stop_loss: number
take_profit: number
entry_time: string
exit_time: string | null
pnl_pips: number | null
status: string
signal_type: string
}
export interface BotStatus {
running: boolean
instrument: string
granularity: string
started_at: string | null
strategy: string
strategy_params: Record<string, unknown>
}
// ─── API calls ───────────────────────────────────────────────────────────────
export const fetchCandles = (
instrument: string,
granularity: string,
count = 200,
) =>
api
.get<{ candles: Candle[] }>('/candles', {
params: { instrument, granularity, count },
})
.then((r) => r.data.candles)
export const fetchTrades = (params?: {
source?: string
status?: string
limit?: number
}) =>
api
.get<{ trades: Trade[] }>('/trades', { params })
.then((r) => r.data.trades)
export const fetchBotStatus = () =>
api.get<BotStatus>('/bot/status').then((r) => r.data)
export const startBot = (instrument?: string, granularity?: string) =>
api
.post<BotStatus>('/bot/start', { instrument, granularity })
.then((r) => r.data)
export const stopBot = () =>
api.post('/bot/stop').then((r) => r.data)
export const runBacktest = (params: {
instrument: string
granularity: string
candle_count: number
initial_balance: number
risk_percent: number
rr_ratio: number
swing_strength: number
liquidity_tolerance_pips: number
spread_pips: number
}) =>
api.post<BacktestResult>('/backtest', params).then((r) => r.data)
export default api

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,51 @@
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'
export default function Backtest() {
const [result, setResult] = useState<BacktestResult | null>(null)
const [candles, setCandles] = useState<Candle[]>([])
const handleResult = async (r: BacktestResult) => {
setResult(r)
// Charger les candles pour la période du backtest
const c = await fetchCandles(r.instrument, r.granularity, 500)
setCandles(c)
}
return (
<div className="p-4 flex gap-4 h-full">
{/* Left: form */}
<div className="w-72 flex-shrink-0">
<BacktestForm onResult={handleResult} />
</div>
{/* 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>
{result ? (
<>
{/* Chart avec les trades superposés */}
<CandlestickChart
candles={candles}
trades={result.trades}
height={360}
/>
<BacktestResults result={result} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-[#64748b] text-sm">
<div className="text-center">
<div className="text-4xl mb-3"></div>
<div>Configure les paramètres et lance un backtest</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from 'react'
import CandlestickChart from '../components/Chart/CandlestickChart'
import BotStatusCard from '../components/Dashboard/BotStatus'
import PnLSummary from '../components/Dashboard/PnLSummary'
import TradeList from '../components/Dashboard/TradeList'
import { fetchBotStatus, fetchCandles, fetchTrades } from '../lib/api'
import type { BotStatus, Candle, Trade } from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
const WS_URL = `ws://${window.location.host}/ws/live`
const INSTRUMENTS = ['EUR_USD', 'GBP_USD', 'USD_JPY', 'SPX500_USD', 'NAS100_USD']
const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D']
export default function Dashboard() {
const [candles, setCandles] = useState<Candle[]>([])
const [trades, setTrades] = useState<Trade[]>([])
const [botStatus, setBotStatus] = useState<BotStatus | null>(null)
const [instrument, setInstrument] = useState('EUR_USD')
const [granularity, setGranularity] = useState('H1')
const { lastEvent, connected } = useWebSocket(WS_URL)
const loadData = useCallback(async () => {
const [c, t, s] = await Promise.allSettled([
fetchCandles(instrument, granularity, 200),
fetchTrades({ limit: 50 }),
fetchBotStatus(),
])
if (c.status === 'fulfilled') setCandles(c.value)
else setCandles([])
if (t.status === 'fulfilled') setTrades(t.value)
if (s.status === 'fulfilled') setBotStatus(s.value)
}, [instrument, granularity])
useEffect(() => {
loadData()
}, [loadData])
// Rafraîchir sur les ticks WebSocket
useEffect(() => {
if (lastEvent?.type === 'tick') {
fetchCandles(instrument, granularity, 5).then((newCandles) => {
if (newCandles.length > 0) {
setCandles((prev) => {
const merged = [...prev]
for (const nc of newCandles) {
const idx = merged.findIndex((c) => c.time === nc.time)
if (idx >= 0) merged[idx] = nc
else merged.push(nc)
}
return merged.slice(-500)
})
}
})
if (lastEvent.new_trade) {
fetchTrades({ limit: 50 }).then(setTrades)
}
}
}, [lastEvent, instrument, granularity])
return (
<div className="flex h-full">
{/* Main chart area */}
<div className="flex-1 flex flex-col p-4 gap-4 min-w-0">
{/* Toolbar */}
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-white">Dashboard Live</h1>
<div className="flex items-center gap-2 ml-auto">
<select
value={instrument}
onChange={(e) => setInstrument(e.target.value)}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{INSTRUMENTS.map((i) => <option key={i}>{i}</option>)}
</select>
<select
value={granularity}
onChange={(e) => setGranularity(e.target.value)}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{GRANULARITIES.map((g) => <option key={g}>{g}</option>)}
</select>
<button
onClick={loadData}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-[#64748b] hover:text-white hover:border-[#6366f1] transition-colors"
>
Refresh
</button>
</div>
</div>
{/* Chart */}
<CandlestickChart key={`${instrument}-${granularity}`} candles={candles} trades={trades} height={460} />
{/* Trade list */}
<TradeList trades={trades} />
</div>
{/* Right sidebar */}
<div className="w-64 flex-shrink-0 p-4 flex flex-col gap-4 border-l border-[#2a2d3e]">
<BotStatusCard
status={botStatus}
wsConnected={connected}
onStatusChange={loadData}
/>
<PnLSummary trades={trades} />
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
background: '#0f1117',
surface: '#1a1d27',
border: '#2a2d3e',
'text-primary': '#e2e8f0',
'text-muted': '#64748b',
accent: '#6366f1',
'bull': '#26a69a',
'bear': '#ef5350',
'bull-light': '#26a69a33',
'bear-light': '#ef535033',
},
},
},
plugins: [],
}
export default config

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})