feat: add strategy and backtest services
- strategy_service.py combines opportunity + portfolio signals into unified buy/sell/hold recommendations - backtest_service.py runs walk-forward backtests on historical datasets with virtual cash and positions - CLI adds `strategy` and `backtest` commands with `--decision-interval` and other tuning parameters - Add tests for both services and CLI dispatch - Update CLAUDE.md with new architecture docs - Optimize model weights via opportunity optimizer Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
129
tests/test_backtest_service.py
Normal file
129
tests/test_backtest_service.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for backtest_service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from coinhunter.services import backtest_service
|
||||
|
||||
|
||||
class BacktestServiceTestCase(unittest.TestCase):
|
||||
def _klines(self, closes: list[float], start_ms: int = 0, volumes: list[float] | None = None) -> list[list[float]]:
|
||||
volumes = volumes or [1.0] * len(closes)
|
||||
return [
|
||||
[start_ms + i * 3600000, c * 0.98, c * 1.02, c * 0.97, c, v, 0.0, c * v, 100, 0.0, 0.0, 0.0]
|
||||
for i, (c, v) in enumerate(zip(closes, volumes))
|
||||
]
|
||||
|
||||
def _config(self) -> dict[str, Any]:
|
||||
return {
|
||||
"opportunity": {
|
||||
"entry_threshold": 1.5,
|
||||
"watch_threshold": 0.6,
|
||||
"min_trigger_score": 0.45,
|
||||
"min_setup_score": 0.35,
|
||||
"overlap_penalty": 0.6,
|
||||
"top_n": 10,
|
||||
"scan_limit": 50,
|
||||
"kline_limit": 48,
|
||||
"weights": {},
|
||||
"model_weights": {},
|
||||
},
|
||||
"portfolio": {
|
||||
"add_threshold": 1.5,
|
||||
"hold_threshold": 0.6,
|
||||
"trim_threshold": 0.2,
|
||||
"exit_threshold": -0.2,
|
||||
"max_position_weight": 0.6,
|
||||
"max_positions": 5,
|
||||
},
|
||||
"signal": {
|
||||
"lookback_interval": "1h",
|
||||
},
|
||||
"market": {
|
||||
"default_quote": "USDT",
|
||||
},
|
||||
"trading": {
|
||||
"commission_pct": 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
def _make_dataset(self, closes_by_symbol: dict[str, list[float]], start_iso: str = "2025-12-28T00:00:00Z", sim_start_iso: str = "2025-12-30T00:00:00Z", sim_end_iso: str = "2026-01-01T00:00:00Z") -> Path:
|
||||
from datetime import datetime, timezone
|
||||
start_ms = int(datetime.fromisoformat(start_iso.replace("Z", "+00:00")).timestamp() * 1000)
|
||||
klines: dict[str, dict[str, list[list[float]]]] = {}
|
||||
for symbol, closes in closes_by_symbol.items():
|
||||
klines[symbol] = {"1h": self._klines(closes, start_ms=start_ms)}
|
||||
dataset = {
|
||||
"metadata": {
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"quote": "USDT",
|
||||
"symbols": list(closes_by_symbol.keys()),
|
||||
"plan": {
|
||||
"intervals": ["1h"],
|
||||
"kline_limit": 48,
|
||||
"reference_days": 2.0,
|
||||
"simulate_days": 1.0,
|
||||
"run_days": 1.0,
|
||||
"total_days": 4.0,
|
||||
"start": start_iso,
|
||||
"simulation_start": sim_start_iso,
|
||||
"simulation_end": sim_end_iso,
|
||||
"end": sim_end_iso,
|
||||
},
|
||||
"external_history": {"provider": "disabled", "status": "disabled"},
|
||||
},
|
||||
"klines": klines,
|
||||
}
|
||||
fp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||
json.dump(dataset, fp)
|
||||
fp.close()
|
||||
return Path(fp.name)
|
||||
|
||||
def test_run_backtest_produces_summary(self) -> None:
|
||||
config = self._config()
|
||||
closes = list(range(20, 92))
|
||||
path = self._make_dataset({"BTCUSDT": closes})
|
||||
try:
|
||||
result = backtest_service.run_backtest(config, dataset_path=str(path), initial_cash=10000.0)
|
||||
self.assertIn("summary", result)
|
||||
self.assertIn("trades", result)
|
||||
self.assertIn("equity_curve", result)
|
||||
self.assertIn("parameters", result)
|
||||
summary = result["summary"]
|
||||
self.assertIn("initial_cash", summary)
|
||||
self.assertIn("final_equity", summary)
|
||||
self.assertIn("total_return_pct", summary)
|
||||
self.assertIn("max_drawdown_pct", summary)
|
||||
self.assertIn("win_rate", summary)
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_run_backtest_missing_simulation_dates_raises(self) -> None:
|
||||
config = self._config()
|
||||
path = self._make_dataset({"BTCUSDT": list(range(20, 92))}, sim_start_iso="", sim_end_iso="")
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
backtest_service.run_backtest(config, dataset_path=str(path))
|
||||
finally:
|
||||
path.unlink()
|
||||
|
||||
def test_run_backtest_tracks_equity_curve(self) -> None:
|
||||
config = self._config()
|
||||
# Need ~72 candles to cover 2025-12-28 through 2026-01-01 (warmup + simulation)
|
||||
closes = list(range(20, 92))
|
||||
path = self._make_dataset({"BTCUSDT": closes})
|
||||
try:
|
||||
result = backtest_service.run_backtest(config, dataset_path=str(path), initial_cash=10000.0)
|
||||
self.assertTrue(len(result["equity_curve"]) > 0)
|
||||
first = result["equity_curve"][0]
|
||||
self.assertIn("time", first)
|
||||
self.assertIn("equity", first)
|
||||
self.assertIn("cash", first)
|
||||
self.assertIn("positions_count", first)
|
||||
finally:
|
||||
path.unlink()
|
||||
@@ -336,6 +336,76 @@ class CLITestCase(unittest.TestCase):
|
||||
max_examples=5,
|
||||
)
|
||||
|
||||
def test_strategy_dispatches(self):
|
||||
captured = {}
|
||||
with (
|
||||
patch.object(
|
||||
cli, "load_config", return_value={"binance": {"spot_base_url": "https://test", "recv_window": 5000}, "market": {"default_quote": "USDT"}, "opportunity": {"top_n": 10}}
|
||||
),
|
||||
patch.object(cli, "get_binance_credentials", return_value={"api_key": "k", "api_secret": "s"}),
|
||||
patch.object(cli, "SpotBinanceClient"),
|
||||
patch.object(
|
||||
cli.strategy_service,
|
||||
"generate_trade_signals",
|
||||
return_value={"buy": [{"symbol": "BTCUSDT", "score": 0.82}], "sell": [], "hold": []},
|
||||
),
|
||||
patch.object(
|
||||
cli, "print_output", side_effect=lambda payload, **kwargs: captured.setdefault("payload", payload)
|
||||
),
|
||||
):
|
||||
result = cli.main(["strategy", "-s", "BTCUSDT"])
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["buy"][0]["symbol"], "BTCUSDT")
|
||||
|
||||
def test_backtest_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
with (
|
||||
patch.object(cli, "load_config", return_value=config),
|
||||
patch.object(cli, "_load_spot_client", side_effect=AssertionError("backtest should use dataset only")),
|
||||
patch.object(
|
||||
cli.backtest_service,
|
||||
"run_backtest",
|
||||
return_value={"summary": {"total_return_pct": 5.0, "win_rate": 0.6}, "trades": []},
|
||||
) as backtest_mock,
|
||||
patch.object(
|
||||
cli,
|
||||
"print_output",
|
||||
side_effect=lambda payload, **kwargs: captured.update({"payload": payload, "agent": kwargs["agent"]}),
|
||||
),
|
||||
):
|
||||
result = cli.main(
|
||||
[
|
||||
"backtest",
|
||||
"/tmp/dataset.json",
|
||||
"--initial-cash",
|
||||
"5000",
|
||||
"--max-positions",
|
||||
"3",
|
||||
"--position-size-pct",
|
||||
"20",
|
||||
"--commission-pct",
|
||||
"0.1",
|
||||
"--lookback",
|
||||
"12",
|
||||
"--agent",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(captured["payload"]["summary"]["total_return_pct"], 5.0)
|
||||
self.assertTrue(captured["agent"])
|
||||
backtest_mock.assert_called_once_with(
|
||||
config,
|
||||
dataset_path="/tmp/dataset.json",
|
||||
initial_cash=5000.0,
|
||||
max_positions=3,
|
||||
position_size_pct=0.2,
|
||||
commission_pct=0.001,
|
||||
lookback=12,
|
||||
decision_interval_minutes=None,
|
||||
)
|
||||
|
||||
def test_opportunity_optimize_dispatches_without_private_client(self):
|
||||
captured = {}
|
||||
config = {"market": {"default_quote": "USDT"}, "opportunity": {}}
|
||||
|
||||
100
tests/test_strategy_service.py
Normal file
100
tests/test_strategy_service.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Tests for strategy_service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from coinhunter.services import strategy_service
|
||||
|
||||
|
||||
class StrategyServiceTestCase(unittest.TestCase):
|
||||
def _klines(self, closes: list[float], volumes: list[float] | None = None) -> list[list[float]]:
|
||||
volumes = volumes or [1.0] * len(closes)
|
||||
return [
|
||||
[i * 3600000.0, c * 0.98, c * 1.02, c * 0.97, c, v, 0.0, c * v, 100, 0.0, 0.0, 0.0]
|
||||
for i, (c, v) in enumerate(zip(closes, volumes))
|
||||
]
|
||||
|
||||
def _config(self) -> dict[str, Any]:
|
||||
return {
|
||||
"opportunity": {
|
||||
"entry_threshold": 1.5,
|
||||
"watch_threshold": 0.6,
|
||||
"min_trigger_score": 0.45,
|
||||
"min_setup_score": 0.35,
|
||||
"overlap_penalty": 0.6,
|
||||
"top_n": 10,
|
||||
"scan_limit": 50,
|
||||
"kline_limit": 48,
|
||||
"weights": {},
|
||||
"model_weights": {},
|
||||
},
|
||||
"portfolio": {
|
||||
"add_threshold": 1.5,
|
||||
"hold_threshold": 0.6,
|
||||
"trim_threshold": 0.2,
|
||||
"exit_threshold": -0.2,
|
||||
"max_position_weight": 0.6,
|
||||
},
|
||||
"signal": {
|
||||
"lookback_interval": "1h",
|
||||
},
|
||||
"market": {
|
||||
"default_quote": "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
def test_generate_signals_from_klines_buy_when_entry_and_not_held(self) -> None:
|
||||
config = self._config()
|
||||
closes = list(range(20, 40))
|
||||
klines = {"BTCUSDT": self._klines(closes)}
|
||||
result = strategy_service.generate_signals_from_klines(config, klines_by_symbol=klines, held_positions=[])
|
||||
self.assertIn("buy", result)
|
||||
self.assertIn("sell", result)
|
||||
self.assertIn("hold", result)
|
||||
|
||||
def test_generate_signals_from_klines_sell_when_exit_signal(self) -> None:
|
||||
config = self._config()
|
||||
closes = list(range(40, 20, -1))
|
||||
klines = {"BTCUSDT": self._klines(closes)}
|
||||
held = [{"symbol": "BTCUSDT", "notional_usdt": 1000.0}]
|
||||
result = strategy_service.generate_signals_from_klines(config, klines_by_symbol=klines, held_positions=held)
|
||||
symbols = [s["symbol"] for s in result["sell"]]
|
||||
self.assertIn("BTCUSDT", symbols)
|
||||
|
||||
def test_generate_signals_respects_max_position_weight(self) -> None:
|
||||
config = self._config()
|
||||
config["portfolio"]["max_position_weight"] = 0.01
|
||||
closes = list(range(20, 40))
|
||||
klines = {"BTCUSDT": self._klines(closes)}
|
||||
held = [{"symbol": "BTCUSDT", "notional_usdt": 9999.0}]
|
||||
result = strategy_service.generate_signals_from_klines(config, klines_by_symbol=klines, held_positions=held)
|
||||
buy_symbols = [s["symbol"] for s in result["buy"]]
|
||||
self.assertNotIn("BTCUSDT", buy_symbols)
|
||||
|
||||
@mock.patch("coinhunter.services.portfolio_service.audit_event")
|
||||
@mock.patch("coinhunter.services.opportunity_service.audit_event")
|
||||
def test_generate_trade_signals_dispatches_to_services(self, mock_audit_opp, mock_audit_pf) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.klines.return_value = self._klines(list(range(20, 44)))
|
||||
mock_client.ticker_stats.return_value = [
|
||||
{
|
||||
"symbol": "BTCUSDT",
|
||||
"lastPrice": "30.0",
|
||||
"priceChangePercent": "5.0",
|
||||
"quoteVolume": "1000000",
|
||||
"highPrice": "31.0",
|
||||
"lowPrice": "29.0",
|
||||
}
|
||||
]
|
||||
mock_client.account.return_value = {"balances": [{"asset": "BTC", "free": "0.5", "locked": "0.0"}]}
|
||||
mock_client.exchange_info.return_value = {"symbols": [{"symbol": "BTCUSDT", "status": "TRADING"}]}
|
||||
|
||||
config = self._config()
|
||||
result = strategy_service.generate_trade_signals(config, spot_client=mock_client)
|
||||
self.assertIn("buy", result)
|
||||
self.assertIn("sell", result)
|
||||
self.assertIn("hold", result)
|
||||
Reference in New Issue
Block a user