Initial public release
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# hydra/backtest/broker.py
|
||||
from hydra.backtest.result import Trade
|
||||
from hydra.data.models import Candle
|
||||
from hydra.strategy.signal import Signal
|
||||
|
||||
|
||||
class BacktestBroker:
|
||||
def __init__(
|
||||
self,
|
||||
initial_capital: float,
|
||||
trade_amount_usd: float,
|
||||
commission_pct: float = 0.001,
|
||||
):
|
||||
self._capital = initial_capital
|
||||
self._trade_amount = trade_amount_usd
|
||||
self._commission = commission_pct
|
||||
self._position: dict | None = None # {entry_price, qty, entry_ts, entry_reason}
|
||||
self._trades: list[Trade] = []
|
||||
self._equity_curve: list[dict] = []
|
||||
|
||||
def on_signal(self, signal: Signal, candle: Candle) -> None:
|
||||
if signal.signal == "BUY" and self._position is None:
|
||||
qty = self._trade_amount / candle.close
|
||||
commission = self._trade_amount * self._commission
|
||||
self._capital -= commission
|
||||
self._position = {
|
||||
"entry_price": candle.close,
|
||||
"qty": qty,
|
||||
"entry_ts": candle.open_time,
|
||||
"entry_reason": signal.reason,
|
||||
}
|
||||
elif signal.signal == "SELL" and self._position is not None:
|
||||
self.close_open_position(
|
||||
price=candle.close,
|
||||
ts=candle.open_time,
|
||||
reason=signal.reason,
|
||||
)
|
||||
self._equity_curve.append({"ts": candle.open_time, "equity": self.equity})
|
||||
|
||||
def close_open_position(
|
||||
self, price: float, ts: int, reason: str = "backtest_end"
|
||||
) -> None:
|
||||
if self._position is None:
|
||||
return
|
||||
pos = self._position
|
||||
gross_pnl = (price - pos["entry_price"]) * pos["qty"]
|
||||
commission = price * pos["qty"] * self._commission
|
||||
net_pnl = gross_pnl - commission
|
||||
pnl_pct = (price - pos["entry_price"]) / pos["entry_price"] * 100
|
||||
self._capital += net_pnl
|
||||
self._trades.append(Trade(
|
||||
market="",
|
||||
symbol="",
|
||||
entry_price=pos["entry_price"],
|
||||
exit_price=price,
|
||||
qty=pos["qty"],
|
||||
pnl_usd=round(net_pnl, 6),
|
||||
pnl_pct=round(pnl_pct, 4),
|
||||
entry_ts=pos["entry_ts"],
|
||||
exit_ts=ts,
|
||||
entry_reason=pos["entry_reason"],
|
||||
exit_reason=reason,
|
||||
))
|
||||
self._position = None
|
||||
self._equity_curve.append({"ts": ts, "equity": self.equity})
|
||||
|
||||
@property
|
||||
def trades(self) -> list[Trade]:
|
||||
return self._trades
|
||||
|
||||
@property
|
||||
def equity_curve(self) -> list[dict]:
|
||||
return self._equity_curve
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
return self._capital
|
||||
@@ -0,0 +1,87 @@
|
||||
# hydra/backtest/result.py
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
market: str
|
||||
symbol: str
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
qty: float
|
||||
pnl_usd: float
|
||||
pnl_pct: float
|
||||
entry_ts: int
|
||||
exit_ts: int
|
||||
entry_reason: str
|
||||
exit_reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
market: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
since: int
|
||||
until: int
|
||||
initial_capital: float
|
||||
final_equity: float
|
||||
trades: list[Trade] = field(default_factory=list)
|
||||
equity_curve: list[dict] = field(default_factory=list)
|
||||
metrics: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
trades: list[Trade],
|
||||
equity_curve: list[dict],
|
||||
initial_capital: float,
|
||||
final_equity: float,
|
||||
) -> dict:
|
||||
total_return_pct = (final_equity - initial_capital) / initial_capital * 100
|
||||
total_trades = len(trades)
|
||||
|
||||
if total_trades == 0:
|
||||
return {
|
||||
"total_return_pct": round(total_return_pct, 4),
|
||||
"total_trades": 0,
|
||||
"win_rate": 0.0,
|
||||
"max_drawdown_pct": 0.0,
|
||||
"sharpe_ratio": 0.0,
|
||||
"avg_pnl_usd": 0.0,
|
||||
}
|
||||
|
||||
wins = sum(1 for t in trades if t.pnl_usd > 0)
|
||||
win_rate = wins / total_trades * 100
|
||||
avg_pnl_usd = sum(t.pnl_usd for t in trades) / total_trades
|
||||
|
||||
# Max drawdown from equity curve
|
||||
max_drawdown_pct = 0.0
|
||||
if equity_curve:
|
||||
peak = equity_curve[0]["equity"]
|
||||
for point in equity_curve:
|
||||
eq = point["equity"]
|
||||
if eq > peak:
|
||||
peak = eq
|
||||
dd = (peak - eq) / peak * 100 if peak > 0 else 0.0
|
||||
if dd > max_drawdown_pct:
|
||||
max_drawdown_pct = dd
|
||||
|
||||
# Sharpe ratio (annualized, trade-based)
|
||||
sharpe_ratio = 0.0
|
||||
if total_trades >= 2:
|
||||
import math
|
||||
pnls = [t.pnl_usd for t in trades]
|
||||
mean = sum(pnls) / len(pnls)
|
||||
variance = sum((p - mean) ** 2 for p in pnls) / (len(pnls) - 1)
|
||||
std = math.sqrt(variance)
|
||||
if std > 0:
|
||||
sharpe_ratio = round((mean / std) * math.sqrt(252), 4)
|
||||
|
||||
return {
|
||||
"total_return_pct": round(total_return_pct, 4),
|
||||
"total_trades": total_trades,
|
||||
"win_rate": round(win_rate, 2),
|
||||
"max_drawdown_pct": round(max_drawdown_pct, 4),
|
||||
"sharpe_ratio": sharpe_ratio,
|
||||
"avg_pnl_usd": round(avg_pnl_usd, 4),
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
# hydra/backtest/runner.py
|
||||
from hydra.backtest.broker import BacktestBroker
|
||||
from hydra.backtest.result import BacktestResult, compute_metrics
|
||||
from hydra.data.storage.base import OhlcvStore
|
||||
from hydra.indicator.calculator import IndicatorCalculator
|
||||
from hydra.regime.detector import RegimeDetector
|
||||
from hydra.strategy.signal import SignalGenerator
|
||||
|
||||
_WARMUP = 210 # IndicatorCalculator._MIN_CANDLES
|
||||
|
||||
|
||||
class BacktestRunner:
|
||||
def __init__(
|
||||
self,
|
||||
store: OhlcvStore,
|
||||
calculator: IndicatorCalculator,
|
||||
detector: RegimeDetector,
|
||||
generator: SignalGenerator,
|
||||
initial_capital: float = 10000.0,
|
||||
trade_amount_usd: float = 100.0,
|
||||
commission_pct: float = 0.001,
|
||||
):
|
||||
self._store = store
|
||||
self._calculator = calculator
|
||||
self._detector = detector
|
||||
self._generator = generator
|
||||
self._initial_capital = initial_capital
|
||||
self._trade_amount = trade_amount_usd
|
||||
self._commission = commission_pct
|
||||
|
||||
async def run(
|
||||
self,
|
||||
market: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
since: int,
|
||||
until: int,
|
||||
) -> BacktestResult:
|
||||
if since >= until:
|
||||
raise ValueError(f"since ({since}) must be less than until ({until})")
|
||||
|
||||
candles = await self._store.query(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
limit=100_000,
|
||||
since=None,
|
||||
)
|
||||
# Filter to candles up to 'until'
|
||||
candles = [c for c in candles if c.open_time <= until]
|
||||
|
||||
broker = BacktestBroker(
|
||||
initial_capital=self._initial_capital,
|
||||
trade_amount_usd=self._trade_amount,
|
||||
commission_pct=self._commission,
|
||||
)
|
||||
|
||||
# Find the first index where trading starts: open_time >= since AND index >= WARMUP
|
||||
trading_start_idx = None
|
||||
for i, c in enumerate(candles):
|
||||
if c.open_time >= since and i >= _WARMUP:
|
||||
trading_start_idx = i
|
||||
break
|
||||
|
||||
if trading_start_idx is None:
|
||||
return BacktestResult(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
since=since,
|
||||
until=until,
|
||||
initial_capital=self._initial_capital,
|
||||
final_equity=self._initial_capital,
|
||||
trades=[],
|
||||
equity_curve=[],
|
||||
metrics=compute_metrics([], [], self._initial_capital, self._initial_capital),
|
||||
)
|
||||
|
||||
for i in range(trading_start_idx, len(candles)):
|
||||
window = candles[i - _WARMUP + 1: i + 1]
|
||||
indicators = self._calculator.compute(window)
|
||||
if not indicators:
|
||||
continue
|
||||
candle = candles[i]
|
||||
close = candle.close
|
||||
indicators["close"] = close
|
||||
regime = self._detector.detect(indicators, close)
|
||||
signal = self._generator.generate(indicators, regime, close)
|
||||
broker.on_signal(signal, candle)
|
||||
|
||||
# Close any open position at end of backtest
|
||||
if candles:
|
||||
last = candles[-1]
|
||||
broker.close_open_position(
|
||||
price=last.close, ts=last.open_time, reason="backtest_end"
|
||||
)
|
||||
|
||||
trades = broker.trades
|
||||
for t in trades:
|
||||
t.market = market
|
||||
t.symbol = symbol
|
||||
|
||||
equity_curve = broker.equity_curve
|
||||
final_equity = broker.equity
|
||||
metrics = compute_metrics(trades, equity_curve, self._initial_capital, final_equity)
|
||||
|
||||
return BacktestResult(
|
||||
market=market,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
since=since,
|
||||
until=until,
|
||||
initial_capital=self._initial_capital,
|
||||
final_equity=round(final_equity, 6),
|
||||
trades=trades,
|
||||
equity_curve=equity_curve,
|
||||
metrics=metrics,
|
||||
)
|
||||
Reference in New Issue
Block a user