summaryrefslogtreecommitdiff
path: root/services/strategy-engine/strategies/combined_strategy.py
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 /services/strategy-engine/strategies/combined_strategy.py
parente10d4a96e062818cb2395add1746c733a053c374 (diff)
feat(strategy): add combined strategy with weighted signal voting
Diffstat (limited to 'services/strategy-engine/strategies/combined_strategy.py')
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py88
1 files changed, 88 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