- 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>
101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
"""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)
|