feat: trade log table, strategy params panel, B+W color scheme
Dashboard: - Trade log table: all trades with time, side, size, entry/exit price, PnL, duration in scrollable panel below charts - Strategy params panel: displays all coefficients (z_entry, gamma, obi_entry, grid_levels, etc.) for the selected strategy - Color scheme: professional black/white • positive: #03A9F4 (light blue) • negative: #FF5252 (red) • neutral: #777 (gray) • backgrounds: #0a0a0a / #111 / #181818 • borders: #222 / #333 VBT runner: - _extract_metrics now captures trades from pf.trades.records_readable (Avg Entry Price, Avg Exit Price, PnL, Return, Duration, Direction) - _strategy_params() returns key coefficients per strategy type - _empty_result includes empty trades/params New vbt_server.py: minimal standalone dashboard (no live trading machinery, no memory guard, no broadcast loop) — avoids crashing issues
This commit is contained in:
@@ -442,6 +442,24 @@ class VBTBacktestRunner:
|
|||||||
return coin_map.get(strategy, ["BTC"])
|
return coin_map.get(strategy, ["BTC"])
|
||||||
|
|
||||||
def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict:
|
def _extract_metrics(self, pf, stats, strategy, interval, n_bars) -> dict:
|
||||||
|
# Extract trade records from VectorBT portfolio
|
||||||
|
trades = []
|
||||||
|
try:
|
||||||
|
trade_records = pf.trades.records_readable
|
||||||
|
for _, t in trade_records.iterrows():
|
||||||
|
trades.append({
|
||||||
|
"time": str(t.get("Exit Timestamp", t.get("Entry Timestamp", "")))[:19],
|
||||||
|
"side": "BUY" if str(t.get("Direction", "")) == "Long" else "SELL",
|
||||||
|
"size": round(float(t.get("Size", 0)), 6),
|
||||||
|
"entry_px": round(float(t.get("Avg Entry Price", 0)), 2),
|
||||||
|
"exit_px": round(float(t.get("Avg Exit Price", 0)), 2),
|
||||||
|
"pnl": round(float(t.get("PnL", 0)), 4),
|
||||||
|
"return_pct": round(float(t.get("Return", 0)) * 100, 3),
|
||||||
|
"duration": str(t.get("Duration", "")),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
@@ -456,6 +474,8 @@ class VBTBacktestRunner:
|
|||||||
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
|
"win_rate": round(float(stats.get("Win Rate [%]", 0)) / 100, 3),
|
||||||
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
|
"profit_factor": round(float(stats.get("Profit Factor", 0)), 3),
|
||||||
"expectancy": round(float(stats.get("Expectancy", 0)), 3),
|
"expectancy": round(float(stats.get("Expectancy", 0)), 3),
|
||||||
|
"trades": trades,
|
||||||
|
"params": _strategy_params(strategy),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _empty_result(self, strategy: str, interval: str) -> dict:
|
def _empty_result(self, strategy: str, interval: str) -> dict:
|
||||||
@@ -472,10 +492,28 @@ class VBTBacktestRunner:
|
|||||||
"max_drawdown_pct": 0.0,
|
"max_drawdown_pct": 0.0,
|
||||||
"win_rate": 0.0,
|
"win_rate": 0.0,
|
||||||
"total_trades": 0,
|
"total_trades": 0,
|
||||||
|
"trades": [],
|
||||||
|
"params": _strategy_params(strategy),
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strategy_params(strategy: str) -> dict:
|
||||||
|
"""Return the key parameters/coefficients for a strategy."""
|
||||||
|
params = {
|
||||||
|
"pairs": {"z_entry": 1.5, "z_exit": 0.5, "lookback": 20, "type": "Stat Arb"},
|
||||||
|
"hurst_vpin": {"hurst_entry": 0.55, "hurst_exit": 0.45, "vpin_threshold": 0.25, "vpin_window": 50, "hurst_window": 64, "type": "Directional"},
|
||||||
|
"as_mm": {"gamma": 0.1, "sigma_dynamic": True, "inventory_skew": True, "type": "Market Making"},
|
||||||
|
"obi": {"obi_lookback": 20, "obi_entry": 0.35, "obi_exit": 0.10, "type": "Reversal"},
|
||||||
|
"grid_mm": {"grid_levels": 10, "grid_spacing_pct": 0.1, "rebalance_every": 20, "type": "Market Making"},
|
||||||
|
"composite_mm": {"obi_weight": 0.30, "as_weight": 0.40, "hurst_weight": 0.30, "entry_score": 0.50, "type": "Ensemble"},
|
||||||
|
"iceberg": {"vol_mult": 1.8, "min_consec": 3, "max_hold": 8, "type": "Momentum"},
|
||||||
|
"momentum": {"bollinger_window": 20, "bollinger_std": 2.0, "type": "Momentum"},
|
||||||
|
"mean_rev": {"vwap_window": 20, "deviation": 1.0, "type": "Reversal"},
|
||||||
|
}
|
||||||
|
return params.get(strategy, {"type": "Unknown"})
|
||||||
|
|
||||||
|
|
||||||
def _generate_signals_sweep(
|
def _generate_signals_sweep(
|
||||||
strategy: str,
|
strategy: str,
|
||||||
data: dict[str, pd.DataFrame],
|
data: dict[str, pd.DataFrame],
|
||||||
|
|||||||
+157
-212
@@ -3,53 +3,65 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>FTDT Quant Lab — VectorBT Dashboard</title>
|
<title>FTDT Quant Lab — VBT Dashboard</title>
|
||||||
<script src="https://cdn.plot.ly/plotly-3.1.0.min.js"></script>
|
<script src="https://cdn.plot.ly/plotly-3.1.0.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{font-family:'SF Mono','Cascadia Code','Ubuntu Mono',monospace;background:#090d14;color:#a8b2c0;min-height:100vh;overflow:hidden}
|
body{font-family:'SF Mono','Cascadia Code','Ubuntu Mono',monospace;background:#0a0a0a;color:#888;min-height:100vh;overflow:hidden}
|
||||||
.topbar{background:#0d1321;border-bottom:1px solid #1a2332;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
|
.pos{color:#03A9F4}.neg{color:#FF5252}.neu{color:#777}
|
||||||
.topbar h1{font-size:13px;font-weight:600;color:#e0e6ed;letter-spacing:1px}
|
.topbar{background:#111;border-bottom:1px solid #222;padding:8px 20px;display:flex;justify-content:space-between;align-items:center;height:40px}
|
||||||
.topbar .dot{display:inline-block;width:6px;height:6px;background:#22c55e;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
|
.topbar h1{font-size:13px;font-weight:600;color:#ddd;letter-spacing:1px}
|
||||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
.topbar .dot{display:inline-block;width:6px;height:6px;background:#fff;border-radius:50%;margin-right:5px;animation:pulse 2s infinite}
|
||||||
.topbar .status{font-size:10px;color:#4ade80}
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||||||
|
.topbar .status{font-size:10px;color:#999}
|
||||||
.main{display:grid;grid-template-columns:310px 1fr;height:calc(100vh - 40px)}
|
.main{display:grid;grid-template-columns:310px 1fr;height:calc(100vh - 40px)}
|
||||||
.sidebar{background:#0b1019;border-right:1px solid #1a2332;overflow-y:auto;padding:6px}
|
.sidebar{background:#0d0d0d;border-right:1px solid #222;overflow-y:auto;padding:6px}
|
||||||
.controls{padding:6px;border-bottom:1px solid #1a2332;margin-bottom:4px}
|
.controls{padding:6px;border-bottom:1px solid #222;margin-bottom:4px}
|
||||||
.controls .row{display:flex;gap:4px;margin-bottom:4px}
|
.controls .row{display:flex;gap:4px;margin-bottom:4px}
|
||||||
.controls .row:last-child{margin-bottom:0}
|
.controls .row:last-child{margin-bottom:0}
|
||||||
select{background:#111827;border:1px solid #1e293b;color:#c8d6e5;border-radius:3px;padding:5px 6px;font-size:10px;font-family:inherit;cursor:pointer;flex:1;min-width:0}
|
select{background:#181818;border:1px solid #333;color:#aaa;border-radius:3px;padding:5px 6px;font-size:10px;font-family:inherit;cursor:pointer;flex:1;min-width:0}
|
||||||
select:focus{outline:none;border-color:#3b82f6}
|
select:focus{outline:none;border-color:#555}
|
||||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:5px 8px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #1e293b;background:#111827;color:#a8b2c0;font-family:inherit;transition:all .12s;white-space:nowrap}
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:5px 8px;border-radius:3px;font-size:10px;cursor:pointer;border:1px solid #333;background:#181818;color:#aaa;font-family:inherit;transition:all .12s;white-space:nowrap}
|
||||||
.btn:hover{background:#1a2744;border-color:#3b82f6}
|
.btn:hover{background:#222;border-color:#555}
|
||||||
.btn-primary{background:#1d4ed8;border-color:#2563eb;color:#e0e6ed}
|
.btn-primary{background:#03A9F4;border-color:#03A9F4;color:#fff}
|
||||||
.btn-primary:hover{background:#1e40af}
|
.btn-primary:hover{background:#0288d1}
|
||||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
.result-item{background:#111827;border:1px solid #1a2332;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:3px;cursor:pointer;transition:all .12s}
|
.result-item{background:#111;border:1px solid #222;border-left:3px solid transparent;border-radius:3px;padding:7px 8px;margin-bottom:3px;cursor:pointer;transition:all .12s}
|
||||||
.result-item:hover{border-color:#334155;background:#151e30}
|
.result-item:hover{border-color:#333;background:#161616}
|
||||||
.result-item.active{border-color:#3b82f6;border-left-color:#3b82f6;background:#121b2d}
|
.result-item.active{border-color:#03A9F4;border-left-color:#03A9F4;background:#121212}
|
||||||
.result-item .name{font-size:11px;font-weight:600;color:#c8d6e5;display:flex;justify-content:space-between;align-items:center}
|
.result-item .name{font-size:11px;font-weight:600;color:#ccc;display:flex;justify-content:space-between;align-items:center}
|
||||||
.result-item .name .asset{font-size:9px;color:#6366f1;background:#1e1b4b50;border:1px solid #312e81;border-radius:3px;padding:1px 5px}
|
.result-item .name .asset{font-size:9px;color:#03A9F4;background:#0d1f2b;border:1px solid #1a3a4a;border-radius:3px;padding:1px 5px}
|
||||||
.result-item .meta{font-size:9px;color:#445265;margin-top:2px}
|
.result-item .meta{font-size:9px;color:#555;margin-top:2px}
|
||||||
.result-item .stats{display:flex;gap:10px;margin-top:3px;font-size:9px;font-family:monospace}
|
.result-item .stats{display:flex;gap:10px;margin-top:3px;font-size:9px;font-family:monospace}
|
||||||
.pos{color:#4ade80}.neg{color:#f87171}.neu{color:#64748b}
|
.sidebar h3{font-size:9px;text-transform:uppercase;color:#555;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
|
||||||
.sidebar h3{font-size:9px;text-transform:uppercase;color:#445265;margin:8px 0 4px;letter-spacing:2px;padding:0 4px}
|
|
||||||
.badge{font-size:9px;border-radius:3px;padding:1px 4px;margin-left:3px;font-weight:400}
|
.badge{font-size:9px;border-radius:3px;padding:1px 4px;margin-left:3px;font-weight:400}
|
||||||
|
|
||||||
.content{padding:20px 28px;overflow-y:auto}
|
.content{padding:20px 28px;overflow-y:auto}
|
||||||
.content h2{font-size:15px;color:#e0e6ed;margin-bottom:2px}
|
.content h2{font-size:15px;color:#ddd;margin-bottom:2px}
|
||||||
.content .sub{font-size:10px;color:#445265;margin-bottom:16px}
|
.content .sub{font-size:10px;color:#555;margin-bottom:16px}
|
||||||
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
|
.metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px}
|
||||||
.metric{background:#0d1321;border:1px solid #1a2332;border-radius:4px;padding:12px 14px}
|
.metric{background:#111;border:1px solid #222;border-radius:4px;padding:12px 14px}
|
||||||
.metric .label{font-size:8px;text-transform:uppercase;color:#445265;letter-spacing:1.5px;margin-bottom:3px}
|
.metric .label{font-size:8px;text-transform:uppercase;color:#555;letter-spacing:1.5px;margin-bottom:3px}
|
||||||
.metric .value{font-size:20px;font-weight:700;font-family:monospace}
|
.metric .value{font-size:20px;font-weight:700;font-family:monospace}
|
||||||
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
|
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px}
|
||||||
.chart-box{background:#0d1321;border:1px solid #1a2332;border-radius:4px;padding:12px}
|
.chart-box{background:#111;border:1px solid #222;border-radius:4px;padding:12px}
|
||||||
.chart-box h4{font-size:9px;color:#445265;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
|
.chart-box h4{font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:2px}
|
||||||
.chart-box.full{grid-column:1/-1}
|
.chart-box.full{grid-column:1/-1}
|
||||||
.empty{text-align:center;padding:60px 20px;color:#334155}
|
.empty{text-align:center;padding:60px 20px;color:#333}
|
||||||
.empty h2{font-size:14px;margin-bottom:6px;color:#445265}
|
.empty h2{font-size:14px;margin-bottom:6px;color:#555}
|
||||||
.loading{text-align:center;padding:14px;color:#334155;font-size:10px}
|
.loading{text-align:center;padding:14px;color:#333;font-size:10px}
|
||||||
|
|
||||||
|
.params-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:6px;margin-bottom:12px}
|
||||||
|
.param{background:#111;border:1px solid #222;border-radius:3px;padding:8px 10px}
|
||||||
|
.param .k{font-size:9px;color:#555;text-transform:uppercase;margin-bottom:2px}
|
||||||
|
.param .v{font-size:13px;color:#03A9F4;font-family:monospace;font-weight:600}
|
||||||
|
|
||||||
|
.trades-table{width:100%;border-collapse:collapse;font-size:11px;font-family:monospace}
|
||||||
|
.trades-table th{text-align:left;padding:7px 10px;border-bottom:1px solid #222;color:#555;font-weight:500;font-size:9px;text-transform:uppercase;letter-spacing:1px;position:sticky;top:0;background:#111;z-index:1}
|
||||||
|
.trades-table td{padding:5px 10px;border-bottom:1px solid #1a1a1a;color:#aaa}
|
||||||
|
.trades-table tr:hover{background:#151515}
|
||||||
|
.trades-table .pnl-pos{color:#03A9F4}.trades-table .pnl-neg{color:#FF5252}
|
||||||
|
.trade-scroll{max-height:400px;overflow-y:auto;border:1px solid #222;border-radius:4px}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -81,218 +93,151 @@ select:focus{outline:none;border-color:#3b82f6}
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<select id="filter-interval" onchange="loadResults()">
|
<select id="filter-interval" onchange="loadResults()">
|
||||||
<option value="">All Intervals</option>
|
<option value="">All Intervals</option>
|
||||||
<option value="1m">1 minute</option>
|
<option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option>
|
||||||
<option value="5m">5 minutes</option>
|
<option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option>
|
||||||
<option value="15m">15 minutes</option>
|
|
||||||
<option value="1h" selected>1 hour</option>
|
|
||||||
<option value="4h">4 hours</option>
|
|
||||||
<option value="1d">1 day</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select id="filter-sort" onchange="loadResults()">
|
<select id="filter-sort" onchange="loadResults()">
|
||||||
<option value="date">Sort: Latest</option>
|
<option value="date">Latest</option>
|
||||||
<option value="sharpe">Sort: Sharpe</option>
|
<option value="sharpe">Sharpe</option>
|
||||||
<option value="return">Sort: Return %</option>
|
<option value="return">Return %</option>
|
||||||
<option value="dd">Sort: Min DD</option>
|
<option value="dd">Min DD</option>
|
||||||
<option value="trades">Sort: Trades</option>
|
<option value="trades">Trades</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<select id="run-interval">
|
<select id="run-interval"><option value="1m">1m</option><option value="5m">5m</option><option value="15m">15m</option><option value="1h" selected>1h</option><option value="4h">4h</option><option value="1d">1d</option></select>
|
||||||
<option value="1m">1m</option>
|
<select id="run-limit"><option value="100">100b</option><option value="200">200b</option><option value="500" selected>500b</option><option value="1000">1Kb</option><option value="2000">2Kb</option><option value="5000">5Kb</option></select>
|
||||||
<option value="5m">5m</option>
|
<select id="run-coin"><option value="">auto</option><option value="BTC">BTC</option><option value="ETH">ETH</option><option value="SOL">SOL</option></select>
|
||||||
<option value="15m">15m</option>
|
|
||||||
<option value="1h" selected>1h</option>
|
|
||||||
<option value="4h">4h</option>
|
|
||||||
<option value="1d">1d</option>
|
|
||||||
</select>
|
|
||||||
<select id="run-limit">
|
|
||||||
<option value="100">100 bars</option>
|
|
||||||
<option value="200">200 bars</option>
|
|
||||||
<option value="500" selected>500 bars</option>
|
|
||||||
<option value="1000">1000 bars</option>
|
|
||||||
<option value="2000">2000 bars</option>
|
|
||||||
<option value="5000">5000 bars</option>
|
|
||||||
</select>
|
|
||||||
<select id="run-coin">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option value="BTC">BTC</option>
|
|
||||||
<option value="ETH">ETH</option>
|
|
||||||
<option value="SOL">SOL</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button>
|
<button class="btn btn-primary" onclick="runNewBacktest()">▶ Run Backtest</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:9px;color:#334155;padding:2px 4px" id="result-count"></div>
|
<div style="font-size:9px;color:#444;padding:2px 4px" id="result-count"></div>
|
||||||
<div id="results-list"><div class="loading">Loading...</div></div>
|
<div id="results-list"><div class="loading">Loading...</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content" id="content">
|
<div class="content" id="content">
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<h2>SELECT A BACKTEST</h2>
|
<h2>SELECT A BACKTEST</h2>
|
||||||
<p style="font-size:11px">Choose from sidebar or configure params and run a new test</p>
|
<p style="font-size:11px">Choose from sidebar or configure and run a new test</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '';
|
const API='';
|
||||||
const HL_INTERVALS = ['1m','5m','15m','30m','1h','4h','8h','1d','1w'];
|
let currentResult=null, currentFilename=null;
|
||||||
let currentResult = null;
|
|
||||||
let currentFilename = null;
|
|
||||||
|
|
||||||
async function loadResults() {
|
async function loadResults(){
|
||||||
const strat = document.getElementById('filter-strategy').value;
|
const strat=document.getElementById('filter-strategy').value;
|
||||||
const interval = document.getElementById('filter-interval').value;
|
const interval=document.getElementById('filter-interval').value;
|
||||||
const sort = document.getElementById('filter-sort').value;
|
const sort=document.getElementById('filter-sort').value;
|
||||||
const asset = document.getElementById('filter-asset').value;
|
const asset=document.getElementById('filter-asset').value;
|
||||||
|
let url='${API}/api/vbt/results?limit=200&strategy='+strat+'&sort='+sort;
|
||||||
let url = `${API}/api/vbt/results?limit=200&strategy=${strat}&sort=${sort}`;
|
if(interval)url+='&interval='+interval;
|
||||||
if (interval) url += '&interval=' + interval;
|
try{
|
||||||
|
const data=await (await fetch(url)).json();
|
||||||
try {
|
const el=document.getElementById('results-list');
|
||||||
const data = await (await fetch(url)).json();
|
const filtered=asset?data.filter(r=>(r.asset||'').includes(asset)):data;
|
||||||
const el = document.getElementById('results-list');
|
document.getElementById('result-count').textContent=filtered.length+' results';
|
||||||
const filtered = asset ? data.filter(r => (r.asset||'').includes(asset)) : data;
|
if(!filtered.length){el.innerHTML='<div style="padding:8px;color:#555;font-size:10px">No results</div>';return;}
|
||||||
document.getElementById('result-count').textContent = filtered.length + ' results';
|
el.innerHTML=filtered.map((r,i)=>{
|
||||||
|
const c=r.sharpe>0.5?'pos':(r.sharpe<-0.5?'neg':'neu');
|
||||||
if (!filtered.length) { el.innerHTML = '<div style="padding:8px;color:#445265;font-size:10px">No results</div>'; return; }
|
const rc=r.total_return_pct>=0?'pos':'neg';
|
||||||
el.innerHTML = filtered.map((r,i) => {
|
return '<div class="result-item'+(i===0&&!currentResult?' active':'')+'" onclick="selectFile(\''+r.filename+'\')" id="item-'+r.filename+'"><div class="name">'+r.strategy+'<span class="asset">'+(r.asset||'?')+'</span></div><div class="meta">'+(r.interval||'1h')+' · '+(r.total_trades||0)+'t · '+(r.n_bars||0)+'b</div><div class="stats"><span class="'+c+'">S'+(r.sharpe||0).toFixed(2)+'</span><span class="'+rc+'">'+(r.total_return_pct||0).toFixed(1)+'%</span><span class="neu">PF'+(r.profit_factor||0).toFixed(2)+'</span></div></div>';
|
||||||
const c = r.sharpe > 0.5 ? 'pos' : (r.sharpe < -0.5 ? 'neg' : 'neu');
|
|
||||||
const rc = r.total_return_pct >= 0 ? 'pos' : 'neg';
|
|
||||||
return `<div class="result-item${i===0&&!currentResult?' active':''}" onclick="selectFile('${r.filename}')" id="item-${r.filename}">
|
|
||||||
<div class="name">${r.strategy}<span class="asset">${r.asset||'?'}</span></div>
|
|
||||||
<div class="meta">${r.interval||'1h'} · ${r.total_trades||0}t · ${(r.n_bars||0)}b</div>
|
|
||||||
<div class="stats">
|
|
||||||
<span class="${c}">S${(r.sharpe||0).toFixed(2)}</span>
|
|
||||||
<span class="${rc}">${(r.total_return_pct||0).toFixed(1)}%</span>
|
|
||||||
<span class="neu">PF${(r.profit_factor||0).toFixed(2)}</span>
|
|
||||||
<span class="neu">DD${(r.max_drawdown_pct||0).toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
}).join('');
|
||||||
} catch(e) { console.error(e); }
|
}catch(e){console.error(e);}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectFile(filename) {
|
async function selectFile(filename){
|
||||||
document.querySelectorAll('.result-item').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.result-item').forEach(el=>el.classList.remove('active'));
|
||||||
document.getElementById('item-'+filename)?.classList.add('active');
|
document.getElementById('item-'+filename)?.classList.add('active');
|
||||||
currentFilename = filename;
|
currentFilename=filename;
|
||||||
try {
|
try{
|
||||||
const r = await (await fetch(`${API}/api/vbt/result/${filename}`)).json();
|
const r=await (await fetch('${API}/api/vbt/result/'+filename)).json();
|
||||||
if (r.error) { document.getElementById('content').innerHTML = '<div class="empty"><h2>FILE NOT FOUND</h2></div>'; return; }
|
if(r.error){document.getElementById('content').innerHTML='<div class="empty"><h2>FILE NOT FOUND</h2></div>';return;}
|
||||||
currentResult = r;
|
currentResult=r;renderDetail(r);
|
||||||
renderDetail(r);
|
}catch(e){document.getElementById('content').innerHTML='<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';}
|
||||||
} catch(e) {
|
|
||||||
document.getElementById('content').innerHTML = '<div class="empty"><h2>ERROR</h2><p>'+e+'</p></div>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetail(r) {
|
function renderDetail(r){
|
||||||
const ret = r.total_return_pct ?? 0;
|
const ret=r.total_return_pct??0,dd=r.max_drawdown_pct??0,sh=r.sharpe??0;
|
||||||
const dd = r.max_drawdown_pct ?? 0;
|
const wr=(r.win_rate??0)*100,pf=r.profit_factor??0,eq=r.end_equity??10000;
|
||||||
const sh = r.sharpe ?? 0;
|
const tr=r.total_trades??0,nb=r.n_bars??0,so=r.sortino??0;
|
||||||
const wr = (r.win_rate ?? 0) * 100;
|
const asset=r.asset||'-';const params=r.params||{};
|
||||||
const pf = r.profit_factor ?? 0;
|
|
||||||
const eq = r.end_equity ?? 10000;
|
|
||||||
const tr = r.total_trades ?? 0;
|
|
||||||
const nb = r.n_bars ?? 0;
|
|
||||||
const so = r.sortino ?? 0;
|
|
||||||
const asset = r.asset || '-';
|
|
||||||
|
|
||||||
document.getElementById('content').innerHTML = `
|
let params_html='';
|
||||||
<h2>${r.strategy} <span class="badge" style="font-size:10px;background:#1e1b4b50;color:#818cf8;border:1px solid #312e81">${asset}</span></h2>
|
for(const[k,v]of Object.entries(params)){
|
||||||
<div class="sub">${r.interval||'1h'} · ${nb} bars · ${tr} trades · ${r.generated_at||''}</div>
|
let cls='neu';
|
||||||
<div class="metrics-grid">
|
if(typeof v==='number'){
|
||||||
<div class="metric"><div class="label">Total Return</div><div class="value ${ret>=0?'pos':'neg'}">${ret.toFixed(2)}%</div></div>
|
if(k.includes('entry')||k.includes('threshold'))cls=v>0.5?'pos':'neu';
|
||||||
<div class="metric"><div class="label">Sharpe</div><div class="value ${sh>=0?'pos':'neg'}">${sh.toFixed(2)}</div></div>
|
else cls=v>0?'pos':(v<0?'neg':'neu');
|
||||||
<div class="metric"><div class="label">Max DD</div><div class="value neg">${dd.toFixed(2)}%</div></div>
|
}
|
||||||
<div class="metric"><div class="label">Win Rate</div><div class="value ${wr>=50?'pos':'neg'}">${wr.toFixed(0)}%</div></div>
|
params_html+='<div class="param"><div class="k">'+k.replace(/_/g,' ')+'</div><div class="v '+cls+'">'+(typeof v==='number'?v.toFixed(3):v)+'</div></div>';
|
||||||
<div class="metric"><div class="label">Profit Factor</div><div class="value ${pf>=1?'pos':'neg'}">${pf.toFixed(2)}</div></div>
|
}
|
||||||
<div class="metric"><div class="label">Trades</div><div class="value neu">${tr}</div></div>
|
|
||||||
<div class="metric"><div class="label">End Equity</div><div class="value neu">$${eq.toLocaleString()}</div></div>
|
let trades_html='';
|
||||||
<div class="metric"><div class="label">Sortino</div><div class="value ${so>=0?'pos':'neg'}">${so.toFixed(2)}</div></div>
|
const trades=r.trades||[];
|
||||||
</div>
|
if(trades.length){
|
||||||
<div class="chart-row">
|
const rows=trades.map(t=>'<tr><td style="color:#777;font-size:10px">'+String(t.time||'').substring(0,19)+'</td><td class="'+(t.side==='BUY'?'pos':'neg')+'">'+String(t.side||'')+'</td><td>'+Number(t.size||0).toFixed(6)+'</td><td>$'+Number(t.entry_px||0).toFixed(1)+'</td><td>$'+Number(t.exit_px||0).toFixed(1)+'</td><td class="'+(Number(t.pnl||0)>=0?'pnl-pos':'pnl-neg')+'">$'+Number(t.pnl||0).toFixed(4)+'</td><td>'+String(t.duration||'')+'</td></tr>').join('');
|
||||||
<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:300px"></div></div>
|
trades_html='<div class="chart-box full"><h4>Trade Log ('+trades.length+' trades)</h4><div class="trade-scroll"><table class="trades-table"><thead><tr><th>Time</th><th>Side</th><th>Size</th><th>Entry</th><th>Exit</th><th>PnL</th><th>Duration</th></tr></thead><tbody>'+rows+'</tbody></table></div></div>';
|
||||||
</div>
|
}
|
||||||
<div class="chart-row">
|
|
||||||
<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:250px"></div></div>
|
document.getElementById('content').innerHTML=
|
||||||
<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:250px"></div></div>
|
'<h2>'+r.strategy+' <span class="badge" style="font-size:10px;background:#0d1f2b;color:#03A9F4;border:1px solid #1a3a4a">'+asset+'</span></h2>'+
|
||||||
</div>`;
|
'<div class="sub">'+(r.interval||'1h')+' · '+nb+' bars · '+tr+' trades · '+(r.generated_at||'')+'</div>'+
|
||||||
|
'<div class="metrics-grid">'+
|
||||||
|
'<div class="metric"><div class="label">Total Return</div><div class="value '+(ret>=0?'pos':'neg')+'">'+ret.toFixed(2)+'%</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Sharpe</div><div class="value '+(sh>=0?'pos':'neg')+'">'+sh.toFixed(2)+'</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Max DD</div><div class="value neg">'+dd.toFixed(2)+'%</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Win Rate</div><div class="value '+(wr>=50?'pos':'neg')+'">'+wr.toFixed(0)+'%</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Profit Factor</div><div class="value '+(pf>=1?'pos':'neg')+'">'+pf.toFixed(2)+'</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Trades</div><div class="value neu">'+tr+'</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">End Equity</div><div class="value neu">$'+eq.toLocaleString()+'</div></div>'+
|
||||||
|
'<div class="metric"><div class="label">Sortino</div><div class="value '+(so>=0?'pos':'neg')+'">'+so.toFixed(2)+'</div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="chart-box full"><h4>Strategy Parameters</h4><div class="params-grid">'+params_html+'</div></div>'+
|
||||||
|
'<div class="chart-row">'+
|
||||||
|
'<div class="chart-box full"><h4>Equity Curve</h4><div id="chart-eq" style="height:280px"></div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
'<div class="chart-row">'+
|
||||||
|
'<div class="chart-box"><h4>Drawdown</h4><div id="chart-dd" style="height:240px"></div></div>'+
|
||||||
|
'<div class="chart-box"><h4>Period Returns</h4><div id="chart-ret" style="height:240px"></div></div>'+
|
||||||
|
'</div>'+
|
||||||
|
trades_html;
|
||||||
renderCharts(r);
|
renderCharts(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCharts(r) {
|
function renderCharts(r){
|
||||||
const ec = r.equity_curve || [];
|
const ec=r.equity_curve||[];
|
||||||
if (!ec.length) return;
|
if(!ec.length)return;
|
||||||
const times = ec.map(p => p.t), values = ec.map(p => p.v);
|
const times=ec.map(p=>p.t),values=ec.map(p=>p.v);
|
||||||
|
Plotly.newPlot('chart-eq',[{x:times,y:values,type:'scatter',mode:'lines',line:{color:'#03A9F4',width:1.2},fill:'tozeroy',fillcolor:'rgba(3,169,244,0.06)'}],{margin:{t:4,r:12,b:28,l:65},height:280,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},tickprefix:'$'},showlegend:false},{responsive:true,displayModeBar:false});
|
||||||
Plotly.newPlot('chart-eq', [{
|
const peak=values.reduce((a,v,i)=>(a.push(i?Math.max(a[i-1],v):v),a),[]);
|
||||||
x: times, y: values, type: 'scatter', mode: 'lines',
|
const dd=values.map((v,i)=>i?-(peak[i]-v)/peak[i]*100:0);
|
||||||
line: {color: '#3b82f6', width: 1.2},
|
Plotly.newPlot('chart-dd',[{x:times,y:dd,type:'scatter',mode:'none',fill:'tozeroy',fillcolor:'rgba(255,82,82,0.10)',line:{color:'#FF5252',width:0.8}}],{margin:{t:4,r:12,b:28,l:55},height:240,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},showlegend:false},{responsive:true,displayModeBar:false});
|
||||||
fill: 'tozeroy', fillcolor: 'rgba(59,130,246,0.06)'
|
if(values.length>1){
|
||||||
}], {
|
const rets=values.slice(1).map((v,i)=>(v-values[i])/values[i]*100);
|
||||||
margin: {t:4,r:12,b:28,l:65}, height: 300,
|
Plotly.newPlot('chart-ret',[{x:rets,type:'histogram',nbinsx:40,marker:{color:'#03A9F4',opacity:0.6,line:{color:'#111',width:1}}}],{margin:{t:4,r:12,b:28,l:45},height:240,paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',xaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9},ticksuffix:'%'},yaxis:{gridcolor:'#1a1a1a',tickfont:{color:'#444',size:9}},showlegend:false,bargap:0.02},{responsive:true,displayModeBar:false});
|
||||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
|
||||||
xaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}},
|
|
||||||
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}, tickprefix: '$'},
|
|
||||||
showlegend: false
|
|
||||||
}, {responsive: true, displayModeBar: false});
|
|
||||||
|
|
||||||
const peak = values.reduce((a,v,i) => (a.push(i?Math.max(a[i-1],v):v),a), []);
|
|
||||||
const dd = values.map((v,i) => i?-(peak[i]-v)/peak[i]*100:0);
|
|
||||||
Plotly.newPlot('chart-dd', [{
|
|
||||||
x: times, y: dd, type: 'scatter', mode: 'none',
|
|
||||||
fill: 'tozeroy', fillcolor: 'rgba(239,68,68,0.12)',
|
|
||||||
line: {color: '#ef4444', width: 0.8}
|
|
||||||
}], {
|
|
||||||
margin: {t:4,r:12,b:28,l:55}, height: 250,
|
|
||||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
|
||||||
xaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}},
|
|
||||||
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}, ticksuffix: '%'},
|
|
||||||
showlegend: false
|
|
||||||
}, {responsive: true, displayModeBar: false});
|
|
||||||
|
|
||||||
if (values.length > 1) {
|
|
||||||
const rets = values.slice(1).map((v,i) => (v-values[i])/values[i]*100);
|
|
||||||
Plotly.newPlot('chart-ret', [{
|
|
||||||
x: rets, type: 'histogram', nbinsx: 40,
|
|
||||||
marker: {color: '#6366f1', opacity: 0.6, line: {color: '#0d1321', width: 1}}
|
|
||||||
}], {
|
|
||||||
margin: {t:4,r:12,b:28,l:45}, height: 250,
|
|
||||||
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
|
|
||||||
xaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}, ticksuffix: '%'},
|
|
||||||
yaxis: {gridcolor: '#111827', tickfont: {color: '#334155', size: 9}},
|
|
||||||
showlegend: false, bargap: 0.02
|
|
||||||
}, {responsive: true, displayModeBar: false});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runNewBacktest() {
|
async function runNewBacktest(){
|
||||||
const strat = document.getElementById('filter-strategy').value || 'pairs';
|
const strat=document.getElementById('filter-strategy').value||'pairs';
|
||||||
const interval = document.getElementById('run-interval').value;
|
const interval=document.getElementById('run-interval').value;
|
||||||
const limit = document.getElementById('run-limit').value;
|
const limit=document.getElementById('run-limit').value;
|
||||||
const coin = document.getElementById('run-coin').value;
|
const coin=document.getElementById('run-coin').value;
|
||||||
const btn = document.querySelector('.btn-primary');
|
const btn=document.querySelector('.btn-primary');
|
||||||
const orig = btn.textContent;
|
const orig=btn.textContent;btn.textContent='⏳ Running...';btn.disabled=true;
|
||||||
btn.textContent = '⏳ Running...';
|
try{
|
||||||
btn.disabled = true;
|
let url='${API}/api/vbt/run?strategy='+strat+'&interval='+interval+'&limit='+limit;
|
||||||
try {
|
if(coin)url+='&coin='+coin;
|
||||||
let url = `${API}/api/vbt/run?strategy=${strat}&interval=${interval}&limit=${limit}`;
|
const resp=await fetch(url);
|
||||||
if (coin) url += '&coin=' + coin;
|
const data=await resp.json();
|
||||||
const resp = await fetch(url);
|
if(data.error){alert(data.error);btn.textContent=orig;btn.disabled=false;return;}
|
||||||
const data = await resp.json();
|
currentResult=data;currentFilename=data.filename;
|
||||||
if (data.error) { alert(data.error); btn.textContent = orig; btn.disabled = false; return; }
|
renderDetail(data);loadResults();
|
||||||
currentResult = data;
|
}catch(e){alert('Failed: '+e.message);}
|
||||||
currentFilename = data.filename;
|
btn.textContent=orig;btn.disabled=false;
|
||||||
renderDetail(data);
|
|
||||||
loadResults();
|
|
||||||
} catch(e) {
|
|
||||||
alert('Failed: ' + e.message);
|
|
||||||
}
|
|
||||||
btn.textContent = orig;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadResults();
|
loadResults();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
Minimal VBT dashboard server — no live trading, no memory guard, no broadcast.
|
||||||
|
Just serves the VBT dashboard HTML and backtest API endpoints.
|
||||||
|
"""
|
||||||
|
import json, os, sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
project_root = str(Path(__file__).resolve().parent.parent)
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
|
RESULTS_DIR = Path(project_root) / "backtests" / "results"
|
||||||
|
HISTORICAL_DIR = RESULTS_DIR / "historical"
|
||||||
|
STATIC_DIR = Path(project_root) / "dashboard" / "static"
|
||||||
|
os.makedirs(RESULTS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
app = FastAPI(title="FTDT VBT Dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Field normalization ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _normalize(data: dict) -> dict:
|
||||||
|
out = dict(data)
|
||||||
|
if "total_return_pct" not in out:
|
||||||
|
out["total_return_pct"] = out.get("pnl_pct", out.get("ann_return_pct", 0))
|
||||||
|
if out.get("total_return_pct") is None:
|
||||||
|
out["total_return_pct"] = 0
|
||||||
|
if "max_drawdown_pct" not in out:
|
||||||
|
dd = out.get("max_dd_pct", out.get("max_dd"))
|
||||||
|
if dd is not None and isinstance(dd, (int, float)) and abs(dd) < 1:
|
||||||
|
dd = dd * 100
|
||||||
|
out["max_drawdown_pct"] = dd or 0
|
||||||
|
if out.get("max_drawdown_pct") is None:
|
||||||
|
out["max_drawdown_pct"] = 0
|
||||||
|
if "n_bars" not in out:
|
||||||
|
out["n_bars"] = out.get("num_periods", 0)
|
||||||
|
if out.get("n_bars") is None:
|
||||||
|
out["n_bars"] = 0
|
||||||
|
if "profit_factor" not in out:
|
||||||
|
trades = out.get("trades", [])
|
||||||
|
if trades:
|
||||||
|
gross_win = sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
|
||||||
|
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
|
||||||
|
gross_loss = abs(sum(t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0
|
||||||
|
for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) < 0))
|
||||||
|
out["profit_factor"] = round(gross_win / gross_loss, 3) if gross_loss > 0 else 0
|
||||||
|
else:
|
||||||
|
out["profit_factor"] = 0
|
||||||
|
if "total_trades" not in out:
|
||||||
|
out["total_trades"] = len(out.get("trades", []))
|
||||||
|
if out.get("total_trades") is None:
|
||||||
|
out["total_trades"] = 0
|
||||||
|
if not out.get("win_rate") and "trades" in out:
|
||||||
|
trades = out.get("trades", [])
|
||||||
|
if trades:
|
||||||
|
wins = sum(1 for t in trades if (t.get("pnl", t.get("pnl_net", t.get("pnl_gross", 0))) or 0) > 0)
|
||||||
|
out["win_rate"] = round(wins / len(trades), 3)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_asset(strategy_name: str, filename: str) -> str:
|
||||||
|
name = (strategy_name + " " + filename).lower()
|
||||||
|
for key, asset in {
|
||||||
|
"pairs": "BTC/ETH", "order book": "BTC", "obi": "BTC",
|
||||||
|
"iceberg": "BTC", "hurst": "BTC", "vpin": "BTC",
|
||||||
|
"avellaneda": "BTC", "as_mm": "BTC", "grid": "BTC",
|
||||||
|
"composite": "BTC", "funding": "BTC", "kalman": "BTC/ETH",
|
||||||
|
"cartea": "BTC", "gueant": "BTC", "hawkes": "BTC",
|
||||||
|
"deep lob": "BTC", "queue": "BTC",
|
||||||
|
}.items():
|
||||||
|
if key in name:
|
||||||
|
return asset
|
||||||
|
return "BTC" if "btc" in name or "eth" not in name else "ETH"
|
||||||
|
|
||||||
|
|
||||||
|
# ── REST API ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/vbt/results")
|
||||||
|
async def list_results(strategy: str = "", interval: str = "", sort: str = "date", limit: int = 200):
|
||||||
|
results = []
|
||||||
|
for d in [RESULTS_DIR, HISTORICAL_DIR]:
|
||||||
|
if not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
for fname in sorted(os.listdir(d), reverse=True):
|
||||||
|
if not fname.endswith(".json"):
|
||||||
|
continue
|
||||||
|
if strategy and strategy not in fname:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(d, fname)) as f:
|
||||||
|
n = _normalize(json.load(f))
|
||||||
|
if interval and n.get("interval", "1h") != interval:
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"filename": fname,
|
||||||
|
"strategy": n.get("strategy", "unknown"),
|
||||||
|
"asset": _infer_asset(n.get("strategy", ""), fname),
|
||||||
|
"engine": n.get("engine", "vectorbt"),
|
||||||
|
"interval": n.get("interval", "1h"),
|
||||||
|
"sharpe": n.get("sharpe", 0),
|
||||||
|
"sortino": n.get("sortino", 0),
|
||||||
|
"total_return_pct": n["total_return_pct"],
|
||||||
|
"max_drawdown_pct": n["max_drawdown_pct"],
|
||||||
|
"win_rate": n.get("win_rate", 0),
|
||||||
|
"profit_factor": n["profit_factor"],
|
||||||
|
"total_trades": n["total_trades"],
|
||||||
|
"n_bars": n["n_bars"],
|
||||||
|
"generated_at": n.get("generated_at", ""),
|
||||||
|
"has_equity_curve": bool(n.get("equity_curve")),
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
if len(results) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
sort_keys = {
|
||||||
|
"sharpe": ("sharpe", True), "return": ("total_return_pct", True),
|
||||||
|
"dd": ("max_drawdown_pct", False), "trades": ("total_trades", True),
|
||||||
|
}
|
||||||
|
if sort in sort_keys:
|
||||||
|
key, rev = sort_keys[sort]
|
||||||
|
results.sort(key=lambda r: r.get(key, -999 if rev else 999), reverse=rev)
|
||||||
|
else:
|
||||||
|
results.sort(key=lambda r: r.get("generated_at", ""), reverse=True)
|
||||||
|
return JSONResponse(results[:limit])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/result/{filename}")
|
||||||
|
async def get_result(filename: str):
|
||||||
|
for d in [RESULTS_DIR, HISTORICAL_DIR]:
|
||||||
|
fpath = os.path.join(d, filename)
|
||||||
|
if os.path.exists(fpath):
|
||||||
|
with open(fpath) as f:
|
||||||
|
data = _normalize(json.load(f))
|
||||||
|
ec = data.get("equity_curve", [])
|
||||||
|
if ec and len(ec) > 500:
|
||||||
|
data["equity_curve"] = ec[::len(ec)//500]
|
||||||
|
return JSONResponse(data)
|
||||||
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/run")
|
||||||
|
async def run_backtest(strategy: str = "pairs", interval: str = "1h", limit: int = 500, coin: str = ""):
|
||||||
|
try:
|
||||||
|
from backtests.vbt_runner import VBTBacktestRunner
|
||||||
|
runner = VBTBacktestRunner()
|
||||||
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
result = runner.run_strategy(strategy=strategy, interval=interval, limit=limit)
|
||||||
|
if result:
|
||||||
|
if coin:
|
||||||
|
result["asset"] = coin.upper()
|
||||||
|
fname = f"{strategy}_{'' if not coin else coin+'_'}vbt_{ts}.json"
|
||||||
|
fpath = RESULTS_DIR / fname
|
||||||
|
with open(fpath, "w") as f:
|
||||||
|
json.dump(result, f, default=str)
|
||||||
|
result["filename"] = fname
|
||||||
|
return JSONResponse(result)
|
||||||
|
return JSONResponse({"error": "no results"}, status_code=500)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/vbt/strategies")
|
||||||
|
async def list_strategies():
|
||||||
|
return JSONResponse([
|
||||||
|
{"key": "pairs", "name": "Pairs Trading", "coins": ["BTC", "ETH"]},
|
||||||
|
{"key": "hurst_vpin", "name": "Hurst VPIN", "coins": ["BTC"]},
|
||||||
|
{"key": "as_mm", "name": "Avellaneda-Stoikov", "coins": ["BTC"]},
|
||||||
|
{"key": "obi", "name": "Order Book Imbalance", "coins": ["BTC"]},
|
||||||
|
{"key": "grid_mm", "name": "Grid Market Making", "coins": ["BTC"]},
|
||||||
|
{"key": "composite_mm", "name": "Composite MM", "coins": ["BTC"]},
|
||||||
|
{"key": "iceberg", "name": "Iceberg Detection", "coins": ["BTC"]},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Static ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/vbt")
|
||||||
|
async def vbt_page():
|
||||||
|
return FileResponse(STATIC_DIR / "vbt.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return FileResponse(STATIC_DIR / "vbt.html")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn, argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--port", type=int, default=9175)
|
||||||
|
p.add_argument("--host", default="0.0.0.0")
|
||||||
|
args = p.parse_args()
|
||||||
|
print(f"VBT Dashboard → http://{args.host}:{args.port}/vbt")
|
||||||
|
uvicorn.run(app, host=args.host, port=args.port, log_level="error")
|
||||||
Reference in New Issue
Block a user