summaryrefslogtreecommitdiff
path: root/services/strategy-engine
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:11:10 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 17:11:10 +0900
commit76f934f95d3b5cbb96765e7158976e4a4c879fa9 (patch)
tree4dc93becf70e41e9df9cc7bb777c6defc3a85d30 /services/strategy-engine
parentadf5e96542ebd65c7d13ca5e9825071183b3ef13 (diff)
feat(strategy): add parameter validation to all strategies
Diffstat (limited to 'services/strategy-engine')
-rw-r--r--services/strategy-engine/strategies/base.py4
-rw-r--r--services/strategy-engine/strategies/bollinger_strategy.py7
-rw-r--r--services/strategy-engine/strategies/ema_crossover_strategy.py12
-rw-r--r--services/strategy-engine/strategies/grid_strategy.py11
-rw-r--r--services/strategy-engine/strategies/macd_strategy.py14
-rw-r--r--services/strategy-engine/strategies/rsi_strategy.py10
-rw-r--r--services/strategy-engine/strategies/volume_profile_strategy.py13
-rw-r--r--services/strategy-engine/strategies/vwap_strategy.py7
-rw-r--r--services/strategy-engine/tests/test_strategy_validation.py247
9 files changed, 325 insertions, 0 deletions
diff --git a/services/strategy-engine/strategies/base.py b/services/strategy-engine/strategies/base.py
index fdf49ed..cf5e6e4 100644
--- a/services/strategy-engine/strategies/base.py
+++ b/services/strategy-engine/strategies/base.py
@@ -20,3 +20,7 @@ class BaseStrategy(ABC):
def reset(self) -> None:
pass
+
+ def validate_params(self, params: dict) -> list[str]:
+ """Validate parameters and return list of error messages. Empty = valid."""
+ return []
diff --git a/services/strategy-engine/strategies/bollinger_strategy.py b/services/strategy-engine/strategies/bollinger_strategy.py
index bee7ee4..4aceee4 100644
--- a/services/strategy-engine/strategies/bollinger_strategy.py
+++ b/services/strategy-engine/strategies/bollinger_strategy.py
@@ -29,6 +29,13 @@ class BollingerStrategy(BaseStrategy):
self._min_bandwidth = float(params.get("min_bandwidth", 0.02))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._period < 2:
+ raise ValueError(f"Bollinger period must be >= 2, got {self._period}")
+ if self._num_std <= 0:
+ raise ValueError(f"Bollinger num_std must be > 0, got {self._num_std}")
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._closes.clear()
self._was_below_lower = False
diff --git a/services/strategy-engine/strategies/ema_crossover_strategy.py b/services/strategy-engine/strategies/ema_crossover_strategy.py
index 17234a3..b0ccbbf 100644
--- a/services/strategy-engine/strategies/ema_crossover_strategy.py
+++ b/services/strategy-engine/strategies/ema_crossover_strategy.py
@@ -26,6 +26,18 @@ class EmaCrossoverStrategy(BaseStrategy):
self._long_period = int(params.get("long_period", 21))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._short_period >= self._long_period:
+ raise ValueError(
+ f"EMA short_period must be < long_period, "
+ f"got short={self._short_period}, long={self._long_period}"
+ )
+ if self._short_period < 2:
+ raise ValueError(f"EMA short_period must be >= 2, got {self._short_period}")
+ if self._long_period < 2:
+ raise ValueError(f"EMA long_period must be >= 2, got {self._long_period}")
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._closes.clear()
self._prev_short_above = None
diff --git a/services/strategy-engine/strategies/grid_strategy.py b/services/strategy-engine/strategies/grid_strategy.py
index 78e2703..b65264c 100644
--- a/services/strategy-engine/strategies/grid_strategy.py
+++ b/services/strategy-engine/strategies/grid_strategy.py
@@ -27,6 +27,17 @@ class GridStrategy(BaseStrategy):
self._upper_price = float(params["upper_price"])
self._grid_count = int(params.get("grid_count", 5))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+
+ if self._lower_price >= self._upper_price:
+ raise ValueError(
+ f"Grid lower_price must be < upper_price, "
+ f"got lower={self._lower_price}, upper={self._upper_price}"
+ )
+ if self._grid_count < 2:
+ raise ValueError(f"Grid grid_count must be >= 2, got {self._grid_count}")
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
self._grid_levels = list(
np.linspace(self._lower_price, self._upper_price, self._grid_count + 1)
)
diff --git a/services/strategy-engine/strategies/macd_strategy.py b/services/strategy-engine/strategies/macd_strategy.py
index 049574e..e3bb35c 100644
--- a/services/strategy-engine/strategies/macd_strategy.py
+++ b/services/strategy-engine/strategies/macd_strategy.py
@@ -28,6 +28,20 @@ class MacdStrategy(BaseStrategy):
self._signal_period = int(params.get("signal_period", 9))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._fast_period >= self._slow_period:
+ raise ValueError(
+ f"MACD fast_period must be < slow_period, "
+ f"got fast={self._fast_period}, slow={self._slow_period}"
+ )
+ if self._fast_period < 2:
+ raise ValueError(f"MACD fast_period must be >= 2, got {self._fast_period}")
+ if self._slow_period < 2:
+ raise ValueError(f"MACD slow_period must be >= 2, got {self._slow_period}")
+ if self._signal_period < 2:
+ raise ValueError(f"MACD signal_period must be >= 2, got {self._signal_period}")
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._closes.clear()
self._prev_histogram = None
diff --git a/services/strategy-engine/strategies/rsi_strategy.py b/services/strategy-engine/strategies/rsi_strategy.py
index c37957d..59946f4 100644
--- a/services/strategy-engine/strategies/rsi_strategy.py
+++ b/services/strategy-engine/strategies/rsi_strategy.py
@@ -44,6 +44,16 @@ class RsiStrategy(BaseStrategy):
self._overbought = float(params.get("overbought", 70))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._period < 2:
+ raise ValueError(f"RSI period must be >= 2, got {self._period}")
+ if not (0 < self._oversold < self._overbought < 100):
+ raise ValueError(
+ f"RSI thresholds must be 0 < oversold < overbought < 100, "
+ f"got oversold={self._oversold}, overbought={self._overbought}"
+ )
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._closes.clear()
diff --git a/services/strategy-engine/strategies/volume_profile_strategy.py b/services/strategy-engine/strategies/volume_profile_strategy.py
index e9463bf..b91e107 100644
--- a/services/strategy-engine/strategies/volume_profile_strategy.py
+++ b/services/strategy-engine/strategies/volume_profile_strategy.py
@@ -29,6 +29,19 @@ class VolumeProfileStrategy(BaseStrategy):
self._value_area_pct = float(params.get("value_area_pct", 0.7))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._lookback_period < 2:
+ raise ValueError(
+ f"Volume profile lookback_period must be >= 2, got {self._lookback_period}"
+ )
+ if self._num_bins < 2:
+ raise ValueError(f"Volume profile num_bins must be >= 2, got {self._num_bins}")
+ if not (0 < self._value_area_pct <= 1):
+ raise ValueError(
+ f"Volume profile value_area_pct must be 0 < pct <= 1, got {self._value_area_pct}"
+ )
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._candles.clear()
self._was_below_va = False
diff --git a/services/strategy-engine/strategies/vwap_strategy.py b/services/strategy-engine/strategies/vwap_strategy.py
index d1b86b5..78919f1 100644
--- a/services/strategy-engine/strategies/vwap_strategy.py
+++ b/services/strategy-engine/strategies/vwap_strategy.py
@@ -24,6 +24,13 @@ class VwapStrategy(BaseStrategy):
self._deviation_threshold = float(params.get("deviation_threshold", 0.002))
self._quantity = Decimal(str(params.get("quantity", "0.01")))
+ if self._deviation_threshold <= 0:
+ raise ValueError(
+ f"VWAP deviation_threshold must be > 0, got {self._deviation_threshold}"
+ )
+ if self._quantity <= 0:
+ raise ValueError(f"Quantity must be positive, got {self._quantity}")
+
def reset(self) -> None:
self._cumulative_tp_vol = 0.0
self._cumulative_vol = 0.0
diff --git a/services/strategy-engine/tests/test_strategy_validation.py b/services/strategy-engine/tests/test_strategy_validation.py
new file mode 100644
index 0000000..a832a15
--- /dev/null
+++ b/services/strategy-engine/tests/test_strategy_validation.py
@@ -0,0 +1,247 @@
+import pytest
+
+from strategies.rsi_strategy import RsiStrategy
+from strategies.macd_strategy import MacdStrategy
+from strategies.bollinger_strategy import BollingerStrategy
+from strategies.ema_crossover_strategy import EmaCrossoverStrategy
+from strategies.grid_strategy import GridStrategy
+from strategies.vwap_strategy import VwapStrategy
+from strategies.volume_profile_strategy import VolumeProfileStrategy
+
+
+# ── RSI ──────────────────────────────────────────────────────────────────
+
+
+class TestRsiValidation:
+ def test_valid_params(self):
+ s = RsiStrategy()
+ s.configure({"period": 14, "oversold": 30, "overbought": 70, "quantity": "0.01"})
+
+ def test_period_too_small(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="period must be >= 2"):
+ s.configure({"period": 1})
+
+ def test_oversold_gte_overbought(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="thresholds"):
+ s.configure({"oversold": 70, "overbought": 30})
+
+ def test_oversold_equals_overbought(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="thresholds"):
+ s.configure({"oversold": 50, "overbought": 50})
+
+ def test_oversold_zero(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="thresholds"):
+ s.configure({"oversold": 0, "overbought": 70})
+
+ def test_overbought_100(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="thresholds"):
+ s.configure({"oversold": 30, "overbought": 100})
+
+ def test_quantity_zero(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})
+
+ def test_quantity_negative(self):
+ s = RsiStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "-1"})
+
+
+# ── MACD ─────────────────────────────────────────────────────────────────
+
+
+class TestMacdValidation:
+ def test_valid_params(self):
+ s = MacdStrategy()
+ s.configure({"fast_period": 12, "slow_period": 26, "signal_period": 9, "quantity": "0.05"})
+
+ def test_fast_gte_slow(self):
+ s = MacdStrategy()
+ with pytest.raises(ValueError, match="fast_period must be < slow_period"):
+ s.configure({"fast_period": 26, "slow_period": 12})
+
+ def test_fast_equals_slow(self):
+ s = MacdStrategy()
+ with pytest.raises(ValueError, match="fast_period must be < slow_period"):
+ s.configure({"fast_period": 12, "slow_period": 12})
+
+ def test_fast_period_too_small(self):
+ s = MacdStrategy()
+ with pytest.raises(ValueError, match="fast_period must be >= 2"):
+ s.configure({"fast_period": 1, "slow_period": 26})
+
+ def test_signal_period_too_small(self):
+ s = MacdStrategy()
+ with pytest.raises(ValueError, match="signal_period must be >= 2"):
+ s.configure({"signal_period": 1})
+
+ def test_quantity_zero(self):
+ s = MacdStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})
+
+
+# ── Bollinger ────────────────────────────────────────────────────────────
+
+
+class TestBollingerValidation:
+ def test_valid_params(self):
+ s = BollingerStrategy()
+ s.configure({"period": 20, "num_std": 2.0, "quantity": "0.01"})
+
+ def test_period_too_small(self):
+ s = BollingerStrategy()
+ with pytest.raises(ValueError, match="period must be >= 2"):
+ s.configure({"period": 1})
+
+ def test_num_std_zero(self):
+ s = BollingerStrategy()
+ with pytest.raises(ValueError, match="num_std must be > 0"):
+ s.configure({"num_std": 0})
+
+ def test_num_std_negative(self):
+ s = BollingerStrategy()
+ with pytest.raises(ValueError, match="num_std must be > 0"):
+ s.configure({"num_std": -1.0})
+
+ def test_quantity_zero(self):
+ s = BollingerStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})
+
+
+# ── EMA Crossover ────────────────────────────────────────────────────────
+
+
+class TestEmaCrossoverValidation:
+ def test_valid_params(self):
+ s = EmaCrossoverStrategy()
+ s.configure({"short_period": 9, "long_period": 21, "quantity": "0.01"})
+
+ def test_short_gte_long(self):
+ s = EmaCrossoverStrategy()
+ with pytest.raises(ValueError, match="short_period must be < long_period"):
+ s.configure({"short_period": 21, "long_period": 9})
+
+ def test_short_equals_long(self):
+ s = EmaCrossoverStrategy()
+ with pytest.raises(ValueError, match="short_period must be < long_period"):
+ s.configure({"short_period": 10, "long_period": 10})
+
+ def test_short_period_too_small(self):
+ s = EmaCrossoverStrategy()
+ with pytest.raises(ValueError, match="short_period must be >= 2"):
+ s.configure({"short_period": 1, "long_period": 21})
+
+ def test_quantity_zero(self):
+ s = EmaCrossoverStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})
+
+
+# ── Grid ─────────────────────────────────────────────────────────────────
+
+
+class TestGridValidation:
+ def test_valid_params(self):
+ s = GridStrategy()
+ s.configure({"lower_price": 100, "upper_price": 200, "grid_count": 5, "quantity": "0.01"})
+
+ def test_lower_gte_upper(self):
+ s = GridStrategy()
+ with pytest.raises(ValueError, match="lower_price must be < upper_price"):
+ s.configure({"lower_price": 200, "upper_price": 100})
+
+ def test_lower_equals_upper(self):
+ s = GridStrategy()
+ with pytest.raises(ValueError, match="lower_price must be < upper_price"):
+ s.configure({"lower_price": 100, "upper_price": 100})
+
+ def test_grid_count_too_small(self):
+ s = GridStrategy()
+ with pytest.raises(ValueError, match="grid_count must be >= 2"):
+ s.configure({"lower_price": 100, "upper_price": 200, "grid_count": 1})
+
+ def test_quantity_zero(self):
+ s = GridStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"lower_price": 100, "upper_price": 200, "quantity": "0"})
+
+
+# ── VWAP ─────────────────────────────────────────────────────────────────
+
+
+class TestVwapValidation:
+ def test_valid_params(self):
+ s = VwapStrategy()
+ s.configure({"deviation_threshold": 0.002, "quantity": "0.01"})
+
+ def test_deviation_threshold_zero(self):
+ s = VwapStrategy()
+ with pytest.raises(ValueError, match="deviation_threshold must be > 0"):
+ s.configure({"deviation_threshold": 0})
+
+ def test_deviation_threshold_negative(self):
+ s = VwapStrategy()
+ with pytest.raises(ValueError, match="deviation_threshold must be > 0"):
+ s.configure({"deviation_threshold": -0.01})
+
+ def test_quantity_zero(self):
+ s = VwapStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})
+
+
+# ── Volume Profile ───────────────────────────────────────────────────────
+
+
+class TestVolumeProfileValidation:
+ def test_valid_params(self):
+ s = VolumeProfileStrategy()
+ s.configure({
+ "lookback_period": 100,
+ "num_bins": 50,
+ "value_area_pct": 0.7,
+ "quantity": "0.01",
+ })
+
+ def test_lookback_too_small(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="lookback_period must be >= 2"):
+ s.configure({"lookback_period": 1})
+
+ def test_num_bins_too_small(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="num_bins must be >= 2"):
+ s.configure({"num_bins": 1})
+
+ def test_value_area_pct_zero(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="value_area_pct"):
+ s.configure({"value_area_pct": 0})
+
+ def test_value_area_pct_negative(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="value_area_pct"):
+ s.configure({"value_area_pct": -0.5})
+
+ def test_value_area_pct_above_one(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="value_area_pct"):
+ s.configure({"value_area_pct": 1.5})
+
+ def test_value_area_pct_exactly_one(self):
+ """value_area_pct=1.0 is valid (100% of volume)."""
+ s = VolumeProfileStrategy()
+ s.configure({"value_area_pct": 1.0})
+
+ def test_quantity_zero(self):
+ s = VolumeProfileStrategy()
+ with pytest.raises(ValueError, match="Quantity must be positive"):
+ s.configure({"quantity": "0"})