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:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
3377
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
53
frontend/src/App.tsx
Normal file
53
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/Backtest/BacktestForm.tsx
Normal file
116
frontend/src/components/Backtest/BacktestForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
163
frontend/src/components/Backtest/BacktestResults.tsx
Normal file
163
frontend/src/components/Backtest/BacktestResults.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
frontend/src/components/Chart/CandlestickChart.tsx
Normal file
229
frontend/src/components/Chart/CandlestickChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
frontend/src/components/Dashboard/BotStatus.tsx
Normal file
77
frontend/src/components/Dashboard/BotStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
frontend/src/components/Dashboard/PnLSummary.tsx
Normal file
46
frontend/src/components/Dashboard/PnLSummary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
frontend/src/components/Dashboard/TradeList.tsx
Normal file
92
frontend/src/components/Dashboard/TradeList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
frontend/src/hooks/useWebSocket.ts
Normal file
56
frontend/src/hooks/useWebSocket.ts
Normal 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
33
frontend/src/index.css
Normal 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
130
frontend/src/lib/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
51
frontend/src/pages/Backtest.tsx
Normal file
51
frontend/src/pages/Backtest.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
frontend/src/pages/Dashboard.tsx
Normal file
111
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/tailwind.config.ts
Normal file
28
frontend/tailwind.config.ts
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user