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

223
src/data_collector/brain.py Normal file
View File

@ -0,0 +1,223 @@
"""
Brain - Strategy evaluation and decision logging
Pure strategy logic separated from DB I/O for testability
"""
import json
import logging
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Dict, Optional, Any, List
import importlib
from .database import DatabaseManager
from .indicator_engine import IndicatorEngine
from src.strategies.base import BaseStrategy, StrategySignal, SignalType
logger = logging.getLogger(__name__)
# Registry of available strategies
STRATEGY_REGISTRY = {
"ma44_strategy": "src.strategies.ma44_strategy.MA44Strategy",
"ma125_strategy": "src.strategies.ma125_strategy.MA125Strategy",
}
def load_strategy(strategy_name: str) -> BaseStrategy:
"""Dynamically load a strategy class"""
if strategy_name not in STRATEGY_REGISTRY:
# Default fallback or error
logger.warning(f"Strategy {strategy_name} not found, defaulting to MA44")
strategy_name = "ma44_strategy"
module_path, class_name = STRATEGY_REGISTRY[strategy_name].rsplit('.', 1)
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
return cls()
@dataclass
class Decision:
"""A single brain evaluation result"""
time: datetime
symbol: str
interval: str
decision_type: str # "buy", "sell", "hold" -> Now maps to SignalType
strategy: str
confidence: float
price_at_decision: float
indicator_snapshot: Dict[str, Any]
candle_snapshot: Dict[str, Any]
reasoning: str
backtest_id: Optional[str] = None
def to_db_tuple(self) -> tuple:
"""Convert to positional tuple for DB insert"""
return (
self.time,
self.symbol,
self.interval,
self.decision_type,
self.strategy,
self.confidence,
self.price_at_decision,
json.dumps(self.indicator_snapshot),
json.dumps(self.candle_snapshot),
self.reasoning,
self.backtest_id,
)
class Brain:
"""
Evaluates market conditions using a loaded Strategy.
"""
def __init__(
self,
db: DatabaseManager,
indicator_engine: IndicatorEngine,
strategy: str = "ma44_strategy",
):
self.db = db
self.indicator_engine = indicator_engine
self.strategy_name = strategy
self.active_strategy: BaseStrategy = load_strategy(strategy)
logger.info(f"Brain initialized with strategy: {self.active_strategy.name}")
async def evaluate(
self,
symbol: str,
interval: str,
timestamp: datetime,
indicators: Optional[Dict[str, float]] = None,
backtest_id: Optional[str] = None,
current_position: Optional[Dict[str, Any]] = None,
) -> Decision:
"""
Evaluate market conditions and produce a decision.
"""
# Get indicator values
if indicators is None:
indicators = await self.indicator_engine.get_values_at(
symbol, interval, timestamp
)
# Get the triggering candle
candle = await self._get_candle(symbol, interval, timestamp)
if not candle:
return self._create_empty_decision(timestamp, symbol, interval, indicators, backtest_id)
price = float(candle["close"])
candle_dict = {
"time": candle["time"].isoformat(),
"open": float(candle["open"]),
"high": float(candle["high"]),
"low": float(candle["low"]),
"close": price,
"volume": float(candle["volume"]),
}
# Delegate to Strategy
signal: StrategySignal = self.active_strategy.analyze(
candle_dict, indicators, current_position
)
# Build decision
decision = Decision(
time=timestamp,
symbol=symbol,
interval=interval,
decision_type=signal.type.value,
strategy=self.strategy_name,
confidence=signal.confidence,
price_at_decision=price,
indicator_snapshot=indicators,
candle_snapshot=candle_dict,
reasoning=signal.reasoning,
backtest_id=backtest_id,
)
# Store to DB
await self._store_decision(decision)
return decision
def _create_empty_decision(self, timestamp, symbol, interval, indicators, backtest_id):
return Decision(
time=timestamp,
symbol=symbol,
interval=interval,
decision_type="hold",
strategy=self.strategy_name,
confidence=0.0,
price_at_decision=0.0,
indicator_snapshot=indicators or {},
candle_snapshot={},
reasoning="No candle data available",
backtest_id=backtest_id,
)
async def _get_candle(
self,
symbol: str,
interval: str,
timestamp: datetime,
) -> Optional[Dict[str, Any]]:
"""Fetch a specific candle from the database"""
async with self.db.acquire() as conn:
row = await conn.fetchrow("""
SELECT time, open, high, low, close, volume
FROM candles
WHERE symbol = $1 AND interval = $2 AND time = $3
""", symbol, interval, timestamp)
return dict(row) if row else None
async def _store_decision(self, decision: Decision) -> None:
"""Write decision to the decisions table"""
# Note: We might want to skip writing every single HOLD to DB to save space if simulating millions of candles
# But keeping it for now for full traceability
async with self.db.acquire() as conn:
await conn.execute("""
INSERT INTO decisions (
time, symbol, interval, decision_type, strategy,
confidence, price_at_decision, indicator_snapshot,
candle_snapshot, reasoning, backtest_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
""", *decision.to_db_tuple())
async def get_recent_decisions(
self,
symbol: str,
limit: int = 20,
backtest_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Get recent decisions, optionally filtered by backtest_id"""
async with self.db.acquire() as conn:
if backtest_id is not None:
rows = await conn.fetch("""
SELECT time, symbol, interval, decision_type, strategy,
confidence, price_at_decision, indicator_snapshot,
candle_snapshot, reasoning, backtest_id
FROM decisions
WHERE symbol = $1 AND backtest_id = $2
ORDER BY time DESC
LIMIT $3
""", symbol, backtest_id, limit)
else:
rows = await conn.fetch("""
SELECT time, symbol, interval, decision_type, strategy,
confidence, price_at_decision, indicator_snapshot,
candle_snapshot, reasoning, backtest_id
FROM decisions
WHERE symbol = $1 AND backtest_id IS NULL
ORDER BY time DESC
LIMIT $2
""", symbol, limit)
return [dict(row) for row in rows]
def reset_state(self) -> None:
"""Reset internal state tracking"""
pass