Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions
View File
+77
View File
@@ -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
+87
View File
@@ -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),
}
+118
View File
@@ -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,
)