feat: implement strategy metadata and dashboard simulation panel

- Added display_name and description to BaseStrategy
- Updated MA44 and MA125 strategies with metadata
- Added /api/v1/strategies endpoint for dynamic discovery
- Added Strategy Simulation panel to dashboard with date picker and tooltips
- Implemented JS polling for backtest results in dashboard
- Added performance test scripts and DB connection guide
- Expanded indicator config to all 15 timeframes
This commit is contained in:
BTC Bot
2026-02-13 09:50:08 +01:00
parent 38f0a21f56
commit d7bdfcf716
23 changed files with 3623 additions and 241 deletions

View File

@ -396,13 +396,173 @@
font-size: 14px;
}
@media (max-width: 1200px) {
/* Simulation Panel Styles */
.sim-strategies {
display: flex;
flex-direction: column;
gap: 4px;
margin: 8px 0;
}
.sim-strategy-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.sim-strategy-option:hover {
background: var(--tv-hover);
}
.sim-strategy-option input[type="radio"] {
cursor: pointer;
}
.sim-strategy-option label {
cursor: pointer;
flex: 1;
font-size: 13px;
}
.sim-strategy-info {
color: var(--tv-text-secondary);
cursor: help;
position: relative;
font-size: 14px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
}
.sim-strategy-info:hover {
background: var(--tv-hover);
color: var(--tv-text);
}
/* Tooltip */
.sim-strategy-info:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
right: 0;
background: var(--tv-panel-bg);
border: 1px solid var(--tv-border);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
width: 250px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
color: var(--tv-text);
margin-bottom: 4px;
line-height: 1.4;
pointer-events: none;
}
.sim-input-group {
margin-bottom: 12px;
}
.sim-input-group label {
display: block;
font-size: 11px;
color: var(--tv-text-secondary);
margin-bottom: 4px;
text-transform: uppercase;
}
.sim-input {
width: 100%;
padding: 8px 12px;
background: var(--tv-bg);
border: 1px solid var(--tv-border);
border-radius: 4px;
color: var(--tv-text);
font-size: 13px;
font-family: inherit;
}
.sim-input:focus {
outline: none;
border-color: var(--tv-blue);
}
.sim-run-btn {
width: 100%;
padding: 10px;
background: var(--tv-blue);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity 0.2s;
margin-top: 8px;
}
.sim-run-btn:hover:not(:disabled) {
opacity: 0.9;
}
.sim-run-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sim-results {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--tv-border);
}
.sim-stat-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
}
.sim-stat-row span:first-child {
color: var(--tv-text-secondary);
}
.sim-value {
font-weight: 600;
font-family: 'Courier New', monospace;
}
.sim-value.positive { color: var(--tv-green); }
.sim-value.negative { color: var(--tv-red); }
.loading-strategies {
padding: 12px;
text-align: center;
color: var(--tv-text-secondary);
font-size: 12px;
}
@media (max-width: 1400px) {
.ta-content {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1000px) {
.ta-content {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
@media (max-width: 600px) {
.ta-content {
grid-template-columns: 1fr;
}
@ -834,18 +994,47 @@
</div>
</div>
<div class="ta-section">
<div class="ta-section-title">Price Info</div>
<div class="ta-level">
<span class="ta-level-label">Current</span>
<span class="ta-level-value">$${data.current_price.toLocaleString()}</span>
<div class="ta-section" id="simulationPanel">
<div class="ta-section-title">Strategy Simulation</div>
<!-- Date picker -->
<div class="sim-input-group" style="margin: 0 0 8px 0;">
<label style="font-size: 10px; text-transform: uppercase; color: var(--tv-text-secondary);">Start Date:</label>
<input type="datetime-local" id="simStartDate" class="sim-input" style="margin-top: 2px;">
</div>
<div style="font-size: 12px; color: var(--tv-text-secondary); margin-top: 8px;">
Based on last 200 candles<br>
Strategy: Trend following with MA crossovers
<!-- Strategies loaded dynamically here -->
<div id="strategyList" class="sim-strategies" style="max-height: 100px; overflow-y: auto;">
<div class="loading-strategies">Loading strategies...</div>
</div>
<button class="sim-run-btn" onclick="runSimulation()" id="runSimBtn" disabled style="padding: 6px; font-size: 12px; margin-top: 6px;">
Run Simulation
</button>
<!-- Results -->
<div id="simResults" class="sim-results" style="display: none; margin-top: 8px; padding-top: 8px;">
<div class="sim-stat-row" style="padding: 2px 0; font-size: 11px;">
<span>Trades:</span>
<span id="simTrades" class="sim-value">--</span>
</div>
<div class="sim-stat-row" style="padding: 2px 0; font-size: 11px;">
<span>Win Rate:</span>
<span id="simWinRate" class="sim-value">--</span>
</div>
<div class="sim-stat-row" style="padding: 2px 0; font-size: 11px;">
<span>Total P&L:</span>
<span id="simPnL" class="sim-value">--</span>
</div>
</div>
</div>
`;
// Load strategies after simulation panel is rendered
setTimeout(() => {
loadStrategies();
setDefaultStartDate();
}, 0);
}
updateStats(candle) {
@ -891,6 +1080,226 @@
window.open(geminiUrl, '_blank');
}
// Load strategies on page load
async function loadStrategies() {
try {
console.log('Fetching strategies from API...');
// Add timeout to fetch
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const response = await fetch('/api/v1/strategies', {
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('Strategies loaded:', data);
if (!data.strategies) {
throw new Error('Invalid response format: missing strategies array');
}
renderStrategies(data.strategies);
} catch (error) {
console.error('Error loading strategies:', error);
let errorMessage = error.message;
if (error.name === 'AbortError') {
errorMessage = 'Request timeout - API server not responding';
} else if (error.message.includes('Failed to fetch')) {
errorMessage = 'Cannot connect to API server - is it running?';
}
document.getElementById('strategyList').innerHTML =
`<div class="loading-strategies" style="color: var(--tv-red);">
${errorMessage}<br>
<small>Check console (F12) for details</small>
</div>`;
}
}
// Render strategy list
function renderStrategies(strategies) {
const container = document.getElementById('strategyList');
if (!strategies || strategies.length === 0) {
container.innerHTML = '<div class="loading-strategies">No strategies available</div>';
return;
}
container.innerHTML = strategies.map((s, index) => `
<div class="sim-strategy-option">
<input type="radio" name="strategy" id="strat_${s.id}"
value="${s.id}" ${index === 0 ? 'checked' : ''}>
<label for="strat_${s.id}">${s.name}</label>
<span class="sim-strategy-info" data-tooltip="${s.description}">ⓘ</span>
</div>
`).join('');
// Enable run button
document.getElementById('runSimBtn').disabled = false;
}
// Run simulation
async function runSimulation() {
const selectedStrategy = document.querySelector('input[name="strategy"]:checked');
if (!selectedStrategy) {
alert('Please select a strategy');
return;
}
const strategyId = selectedStrategy.value;
const startDateInput = document.getElementById('simStartDate').value;
if (!startDateInput) {
alert('Please select a start date');
return;
}
// Format date for API
const startDate = new Date(startDateInput).toISOString().split('T')[0];
// Disable button during simulation
const runBtn = document.getElementById('runSimBtn');
runBtn.disabled = true;
runBtn.textContent = 'Running...';
try {
// Trigger backtest via API
const response = await fetch('/api/v1/backtests', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
symbol: 'BTC',
intervals: [window.dashboard?.currentInterval || '1d'],
start_date: startDate
})
});
const result = await response.json();
if (result.message) {
// Show that simulation is running
runBtn.textContent = 'Running...';
// Poll for results
setTimeout(() => {
pollForBacktestResults(strategyId, startDate);
}, 2000); // Wait 2 seconds then poll
} else {
alert('Failed to start simulation');
}
} catch (error) {
console.error('Error running simulation:', error);
alert('Error running simulation: ' + error.message);
// Reset button only on error
runBtn.disabled = false;
runBtn.textContent = 'Run Simulation';
}
// Button stays as "Running..." until polling completes or times out
}
// Poll for backtest results
async function pollForBacktestResults(strategyId, startDate, attempts = 0) {
const runBtn = document.getElementById('runSimBtn');
if (attempts > 30) { // Stop after 30 attempts (60 seconds)
console.log('Backtest polling timeout');
runBtn.textContent = 'Run Simulation';
runBtn.disabled = false;
// Show timeout message in results area
const simResults = document.getElementById('simResults');
if (simResults) {
simResults.innerHTML = `
<div class="sim-stat-row" style="color: var(--tv-text-secondary); font-size: 11px; text-align: center;">
<span>Simulation timeout - no results found after 60s.<br>Check server logs or try again.</span>
</div>
`;
simResults.style.display = 'block';
}
return;
}
try {
const response = await fetch('/api/v1/backtests?limit=5');
const backtests = await response.json();
// Find the most recent backtest that matches our criteria
const recentBacktest = backtests.find(bt =>
bt.strategy && bt.strategy.includes(strategyId) ||
bt.created_at > new Date(Date.now() - 60000).toISOString() // Created in last minute
);
if (recentBacktest && recentBacktest.results) {
// Parse JSON string if needed (database stores results as text)
const parsedBacktest = {
...recentBacktest,
results: typeof recentBacktest.results === 'string'
? JSON.parse(recentBacktest.results)
: recentBacktest.results
};
// Results found! Display them
displayBacktestResults(parsedBacktest);
runBtn.textContent = 'Run Simulation';
runBtn.disabled = false;
return;
}
// No results yet, poll again in 2 seconds
setTimeout(() => {
pollForBacktestResults(strategyId, startDate, attempts + 1);
}, 2000);
} catch (error) {
console.error('Error polling for backtest results:', error);
runBtn.textContent = 'Run Simulation';
runBtn.disabled = false;
}
}
// Display backtest results in the UI
function displayBacktestResults(backtest) {
// Parse JSON string if needed (database stores results as text)
const results = typeof backtest.results === 'string'
? JSON.parse(backtest.results)
: backtest.results;
// Update the results display
document.getElementById('simTrades').textContent = results.total_trades || '--';
document.getElementById('simWinRate').textContent = results.win_rate ? results.win_rate.toFixed(1) + '%' : '--';
const pnlElement = document.getElementById('simPnL');
const pnl = results.total_pnl || 0;
pnlElement.textContent = (pnl >= 0 ? '+' : '') + '$' + pnl.toFixed(2);
pnlElement.className = 'sim-value ' + (pnl >= 0 ? 'positive' : 'negative');
// Show results section
document.getElementById('simResults').style.display = 'block';
console.log('Backtest results:', backtest);
}
// Set default start date (7 days ago)
function setDefaultStartDate() {
const startDateInput = document.getElementById('simStartDate');
if (startDateInput) {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Format as datetime-local: YYYY-MM-DDTHH:mm
startDateInput.value = sevenDaysAgo.toISOString().slice(0, 16);
}
}
document.addEventListener('DOMContentLoaded', () => {
window.dashboard = new TradingDashboard();
});

View File

@ -6,17 +6,28 @@ Removes the complex WebSocket manager that was causing issues
import os
import asyncio
import logging
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, BackgroundTasks
from fastapi.staticfiles import StaticFiles
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
import asyncpg
import csv
import io
from pydantic import BaseModel, Field
# Imports for backtest runner
from src.data_collector.database import DatabaseManager
from src.data_collector.indicator_engine import IndicatorEngine, IndicatorConfig
from src.data_collector.brain import Brain
from src.data_collector.backtester import Backtester
# Imports for strategy discovery
import importlib
from src.strategies.base import BaseStrategy
logging.basicConfig(level=logging.INFO)
@ -88,6 +99,41 @@ async def root():
}
@app.get("/api/v1/strategies")
async def list_strategies():
"""List all available trading strategies with metadata"""
# Strategy registry from brain.py
strategy_registry = {
"ma44_strategy": "src.strategies.ma44_strategy.MA44Strategy",
"ma125_strategy": "src.strategies.ma125_strategy.MA125Strategy",
}
strategies = []
for strategy_id, class_path in strategy_registry.items():
try:
module_path, class_name = class_path.rsplit('.', 1)
module = importlib.import_module(module_path)
strategy_class = getattr(module, class_name)
# Instantiate to get metadata
strategy_instance = strategy_class()
strategies.append({
"id": strategy_id,
"name": strategy_instance.display_name,
"description": strategy_instance.description,
"required_indicators": strategy_instance.required_indicators
})
except Exception as e:
logger.error(f"Failed to load strategy {strategy_id}: {e}")
return {
"strategies": strategies,
"count": len(strategies)
}
@app.get("/api/v1/candles")
async def get_candles(
symbol: str = Query("BTC", description="Trading pair symbol"),
@ -215,6 +261,155 @@ async def health_check():
raise HTTPException(status_code=503, detail=f"Health check failed: {str(e)}")
@app.get("/api/v1/indicators")
async def get_indicators(
symbol: str = Query("BTC", description="Trading pair symbol"),
interval: str = Query("1d", description="Candle interval"),
name: str = Query(None, description="Filter by indicator name (e.g., ma44)"),
start: Optional[datetime] = Query(None, description="Start time"),
end: Optional[datetime] = Query(None, description="End time"),
limit: int = Query(1000, le=5000)
):
"""Get indicator values"""
async with pool.acquire() as conn:
query = """
SELECT time, indicator_name, value
FROM indicators
WHERE symbol = $1 AND interval = $2
"""
params = [symbol, interval]
if name:
query += f" AND indicator_name = ${len(params) + 1}"
params.append(name)
if start:
query += f" AND time >= ${len(params) + 1}"
params.append(start)
if end:
query += f" AND time <= ${len(params) + 1}"
params.append(end)
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
# Group by time for easier charting
grouped = {}
for row in rows:
ts = row['time'].isoformat()
if ts not in grouped:
grouped[ts] = {'time': ts}
grouped[ts][row['indicator_name']] = float(row['value'])
return {
"symbol": symbol,
"interval": interval,
"data": list(grouped.values())
}
@app.get("/api/v1/decisions")
async def get_decisions(
symbol: str = Query("BTC"),
interval: Optional[str] = Query(None),
backtest_id: Optional[str] = Query(None),
limit: int = Query(100, le=1000)
):
"""Get brain decisions"""
async with pool.acquire() as conn:
query = """
SELECT time, interval, decision_type, strategy, confidence,
price_at_decision, indicator_snapshot, reasoning, backtest_id
FROM decisions
WHERE symbol = $1
"""
params = [symbol]
if interval:
query += f" AND interval = ${len(params) + 1}"
params.append(interval)
if backtest_id:
query += f" AND backtest_id = ${len(params) + 1}"
params.append(backtest_id)
else:
query += " AND backtest_id IS NULL"
query += f" ORDER BY time DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
return [dict(row) for row in rows]
@app.get("/api/v1/backtests")
async def list_backtests(symbol: Optional[str] = None, limit: int = 20):
"""List historical backtests"""
async with pool.acquire() as conn:
query = """
SELECT id, strategy, symbol, start_time, end_time,
intervals, results, created_at
FROM backtest_runs
"""
params = []
if symbol:
query += " WHERE symbol = $1"
params.append(symbol)
query += f" ORDER BY created_at DESC LIMIT ${len(params) + 1}"
params.append(limit)
rows = await conn.fetch(query, *params)
return [dict(row) for row in rows]
class BacktestRequest(BaseModel):
symbol: str = "BTC"
intervals: list[str] = ["37m"]
start_date: str = "2025-01-01" # ISO date
end_date: Optional[str] = None
async def run_backtest_task(req: BacktestRequest):
"""Background task to run backtest"""
db = DatabaseManager(
host=DB_HOST, port=DB_PORT, database=DB_NAME,
user=DB_USER, password=DB_PASSWORD
)
await db.connect()
try:
# Load configs (hardcoded for now to match main.py)
configs = [
IndicatorConfig("ma44", "sma", 44, req.intervals),
IndicatorConfig("ma125", "sma", 125, req.intervals)
]
engine = IndicatorEngine(db, configs)
brain = Brain(db, engine)
backtester = Backtester(db, engine, brain)
start = datetime.fromisoformat(req.start_date).replace(tzinfo=timezone.utc)
end = datetime.fromisoformat(req.end_date).replace(tzinfo=timezone.utc) if req.end_date else datetime.now(timezone.utc)
await backtester.run(req.symbol, req.intervals, start, end)
except Exception as e:
logger.error(f"Backtest failed: {e}")
finally:
await db.disconnect()
@app.post("/api/v1/backtests")
async def trigger_backtest(req: BacktestRequest, background_tasks: BackgroundTasks):
"""Start a backtest in the background"""
background_tasks.add_task(run_backtest_task, req)
return {"message": "Backtest started", "params": req.dict()}
@app.get("/api/v1/ta")
async def get_technical_analysis(
symbol: str = Query("BTC", description="Trading pair symbol"),
@ -222,42 +417,44 @@ async def get_technical_analysis(
):
"""
Get technical analysis for a symbol
Calculates MA 44, MA 125, trend, support/resistance
Uses stored indicators from DB if available, falls back to on-the-fly calc
"""
try:
async with pool.acquire() as conn:
# Get enough candles for MA 125 calculation
rows = await conn.fetch("""
SELECT time, open, high, low, close, volume
# 1. Get latest price
latest = await conn.fetchrow("""
SELECT close, time
FROM candles
WHERE symbol = $1 AND interval = $2
ORDER BY time DESC
LIMIT 200
LIMIT 1
""", symbol, interval)
if len(rows) < 50:
return {
"symbol": symbol,
"interval": interval,
"error": "Not enough data for technical analysis",
"min_required": 50,
"available": len(rows)
}
if not latest:
return {"error": "No candle data found"}
current_price = float(latest['close'])
timestamp = latest['time']
# Reverse to chronological order
candles = list(reversed(rows))
closes = [float(c['close']) for c in candles]
# 2. Get latest indicators from DB
indicators = await conn.fetch("""
SELECT indicator_name, value
FROM indicators
WHERE symbol = $1 AND interval = $2
AND time <= $3
ORDER BY time DESC
""", symbol, interval, timestamp)
# Calculate Moving Averages
def calculate_ma(data, period):
if len(data) < period:
return None
return sum(data[-period:]) / period
# Convert list to dict, e.g. {'ma44': 65000, 'ma125': 64000}
# We take the most recent value for each indicator
ind_map = {}
for row in indicators:
name = row['indicator_name']
if name not in ind_map:
ind_map[name] = float(row['value'])
ma_44 = calculate_ma(closes, 44)
ma_125 = calculate_ma(closes, 125)
current_price = closes[-1]
ma_44 = ind_map.get('ma44')
ma_125 = ind_map.get('ma125')
# Determine trend
if ma_44 and ma_125:
@ -274,24 +471,35 @@ async def get_technical_analysis(
trend = "Unknown"
trend_strength = "Insufficient data"
# Find support and resistance (recent swing points)
highs = [float(c['high']) for c in candles[-20:]]
lows = [float(c['low']) for c in candles[-20:]]
# 3. Find support/resistance (simple recent high/low)
rows = await conn.fetch("""
SELECT high, low
FROM candles
WHERE symbol = $1 AND interval = $2
ORDER BY time DESC
LIMIT 20
""", symbol, interval)
resistance = max(highs)
support = min(lows)
# Calculate price position
price_range = resistance - support
if price_range > 0:
position = (current_price - support) / price_range * 100
if rows:
highs = [float(r['high']) for r in rows]
lows = [float(r['low']) for r in rows]
resistance = max(highs)
support = min(lows)
price_range = resistance - support
if price_range > 0:
position = (current_price - support) / price_range * 100
else:
position = 50
else:
resistance = current_price
support = current_price
position = 50
return {
"symbol": symbol,
"interval": interval,
"timestamp": datetime.utcnow().isoformat(),
"timestamp": timestamp.isoformat(),
"current_price": round(current_price, 2),
"moving_averages": {
"ma_44": round(ma_44, 2) if ma_44 else None,