summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:26:03 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:26:03 +0900
commit7d7ecadca4f7416eb252afce750e75e696d54a94 (patch)
treeae458c0772ec21b800865f0c0134d5bac0dc4c94
parente10d4a96e062818cb2395add1746c733a053c374 (diff)
feat(strategy): add combined strategy with weighted signal voting
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py88
-rw-r--r--services/strategy-engine/strategies/config/combined_strategy.yaml2
-rw-r--r--services/strategy-engine/tests/test_combined_strategy.py158
3 files changed, 248 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/combined_strategy.py b/services/strategy-engine/strategies/combined_strategy.py
new file mode 100644
index 0000000..e99dfdf
--- /dev/null
+++ b/services/strategy-engine/strategies/combined_strategy.py
@@ -0,0 +1,88 @@
+"""Combined strategy that aggregates signals from multiple sub-strategies."""
+from decimal import Decimal
+from typing import Optional
+
+from shared.models import Candle, Signal, OrderSide
+from strategies.base import BaseStrategy
+
+
+class CombinedStrategy(BaseStrategy):
+ """Combines multiple strategies using weighted signal voting.
+
+ Each sub-strategy votes BUY (+weight), SELL (-weight), or HOLD (0).
+ The combined signal fires when the weighted sum exceeds a threshold.
+ """
+ name: str = "combined"
+
+ def __init__(self) -> None:
+ self._strategies: list[tuple[BaseStrategy, float]] = [] # (strategy, weight)
+ self._threshold: float = 0.5
+ self._quantity: Decimal = Decimal("0.01")
+
+ @property
+ def warmup_period(self) -> int:
+ if not self._strategies:
+ return 0
+ return max(s.warmup_period for s, _ in self._strategies)
+
+ def configure(self, params: dict) -> None:
+ self._threshold = float(params.get("threshold", 0.5))
+ self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._threshold <= 0:
+ raise ValueError(f"Threshold must be positive, got {self._threshold}")
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
+ def add_strategy(self, strategy: BaseStrategy, weight: float = 1.0) -> None:
+ """Add a sub-strategy with a weight."""
+ if weight <= 0:
+ raise ValueError(f"Weight must be positive, got {weight}")
+ self._strategies.append((strategy, weight))
+
+ def reset(self) -> None:
+ for strategy, _ in self._strategies:
+ strategy.reset()
+
+ def on_candle(self, candle: Candle) -> Signal | None:
+ if not self._strategies:
+ return None
+
+ total_weight = sum(w for _, w in self._strategies)
+ if total_weight == 0:
+ return None
+
+ score = 0.0
+ reasons = []
+
+ for strategy, weight in self._strategies:
+ signal = strategy.on_candle(candle)
+ if signal is not None:
+ if signal.side == OrderSide.BUY:
+ score += weight
+ reasons.append(f"{strategy.name}:BUY({weight})")
+ elif signal.side == OrderSide.SELL:
+ score -= weight
+ reasons.append(f"{strategy.name}:SELL({weight})")
+
+ normalized = score / total_weight # Range: -1.0 to 1.0
+
+ if normalized >= self._threshold:
+ return Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ reason=f"Combined score {normalized:.2f} >= {self._threshold} [{', '.join(reasons)}]",
+ )
+ elif normalized <= -self._threshold:
+ return Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ reason=f"Combined score {normalized:.2f} <= -{self._threshold} [{', '.join(reasons)}]",
+ )
+
+ return None
diff --git a/services/strategy-engine/strategies/config/combined_strategy.yaml b/services/strategy-engine/strategies/config/combined_strategy.yaml
new file mode 100644
index 0000000..9b5a575
--- /dev/null
+++ b/services/strategy-engine/strategies/config/combined_strategy.yaml
@@ -0,0 +1,2 @@
+threshold: 0.5
+quantity: "0.01"
diff --git a/services/strategy-engine/tests/test_combined_strategy.py b/services/strategy-engine/tests/test_combined_strategy.py
new file mode 100644
index 0000000..b860dca
--- /dev/null
+++ b/services/strategy-engine/tests/test_combined_strategy.py
@@ -0,0 +1,158 @@
+"""Tests for Combined strategy."""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from decimal import Decimal
+from datetime import datetime, timezone
+import pytest
+
+from shared.models import Candle, Signal, OrderSide
+from strategies.combined_strategy import CombinedStrategy
+from strategies.base import BaseStrategy
+
+
+class AlwaysBuyStrategy(BaseStrategy):
+ name = "always_buy"
+
+ @property
+ def warmup_period(self) -> int:
+ return 0
+
+ def configure(self, params: dict) -> None:
+ pass
+
+ def on_candle(self, candle: Candle) -> Signal | None:
+ return Signal(
+ strategy=self.name, symbol=candle.symbol,
+ side=OrderSide.BUY, price=candle.close,
+ quantity=Decimal("0.01"), reason="always buy",
+ )
+
+
+class AlwaysSellStrategy(BaseStrategy):
+ name = "always_sell"
+
+ @property
+ def warmup_period(self) -> int:
+ return 0
+
+ def configure(self, params: dict) -> None:
+ pass
+
+ def on_candle(self, candle: Candle) -> Signal | None:
+ return Signal(
+ strategy=self.name, symbol=candle.symbol,
+ side=OrderSide.SELL, price=candle.close,
+ quantity=Decimal("0.01"), reason="always sell",
+ )
+
+
+class NeutralStrategy(BaseStrategy):
+ name = "neutral"
+
+ @property
+ def warmup_period(self) -> int:
+ return 0
+
+ def configure(self, params: dict) -> None:
+ pass
+
+ def on_candle(self, candle: Candle) -> Signal | None:
+ return None
+
+
+def _candle(price=100.0):
+ return Candle(
+ symbol="BTCUSDT", timeframe="1m",
+ open_time=datetime(2025, 1, 1, tzinfo=timezone.utc),
+ open=Decimal(str(price)), high=Decimal(str(price+10)),
+ low=Decimal(str(price-10)), close=Decimal(str(price)),
+ volume=Decimal("10"),
+ )
+
+
+def test_combined_no_strategies():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.5})
+ assert c.on_candle(_candle()) is None
+
+
+def test_combined_unanimous_buy():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.5})
+ c.add_strategy(AlwaysBuyStrategy(), weight=1.0)
+ c.add_strategy(AlwaysBuyStrategy(), weight=1.0)
+ sig = c.on_candle(_candle())
+ assert sig is not None
+ assert sig.side == OrderSide.BUY
+
+
+def test_combined_unanimous_sell():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.5})
+ c.add_strategy(AlwaysSellStrategy(), weight=1.0)
+ c.add_strategy(AlwaysSellStrategy(), weight=1.0)
+ sig = c.on_candle(_candle())
+ assert sig is not None
+ assert sig.side == OrderSide.SELL
+
+
+def test_combined_conflicting_signals_cancel():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.5})
+ c.add_strategy(AlwaysBuyStrategy(), weight=1.0)
+ c.add_strategy(AlwaysSellStrategy(), weight=1.0)
+ sig = c.on_candle(_candle())
+ assert sig is None # Score = 0, below threshold
+
+
+def test_combined_weighted_buy():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.3})
+ c.add_strategy(AlwaysBuyStrategy(), weight=3.0)
+ c.add_strategy(AlwaysSellStrategy(), weight=1.0)
+ sig = c.on_candle(_candle())
+ assert sig is not None
+ assert sig.side == OrderSide.BUY # Score = (3-1)/4 = 0.5 >= 0.3
+
+
+def test_combined_neutral_doesnt_affect_score():
+ c = CombinedStrategy()
+ c.configure({"threshold": 0.5})
+ c.add_strategy(AlwaysBuyStrategy(), weight=1.0)
+ c.add_strategy(NeutralStrategy(), weight=1.0)
+ sig = c.on_candle(_candle())
+ assert sig is not None
+ assert sig.side == OrderSide.BUY # Score = 1/2 = 0.5 >= 0.5
+
+
+def test_combined_warmup_is_max():
+ c = CombinedStrategy()
+ c.configure({})
+ s1 = AlwaysBuyStrategy()
+ s2 = NeutralStrategy()
+ c.add_strategy(s1)
+ c.add_strategy(s2)
+ assert c.warmup_period == 0
+
+
+def test_combined_reset_resets_all():
+ c = CombinedStrategy()
+ c.configure({})
+ c.add_strategy(AlwaysBuyStrategy())
+ c.on_candle(_candle())
+ c.reset() # Should not crash
+
+
+def test_combined_invalid_threshold():
+ c = CombinedStrategy()
+ with pytest.raises(ValueError):
+ c.configure({"threshold": -1})
+
+
+def test_combined_invalid_weight():
+ c = CombinedStrategy()
+ c.configure({})
+ with pytest.raises(ValueError):
+ c.add_strategy(AlwaysBuyStrategy(), weight=-1.0)