summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
Diffstat (limited to 'services')
-rw-r--r--services/order-executor/src/order_executor/risk_manager.py30
-rw-r--r--services/order-executor/tests/test_risk_manager.py108
-rw-r--r--services/strategy-engine/strategies/asian_session_rsi.py106
-rw-r--r--services/strategy-engine/strategies/bollinger_strategy.py40
-rw-r--r--services/strategy-engine/strategies/combined_strategy.py12
-rw-r--r--services/strategy-engine/strategies/grid_strategy.py21
-rw-r--r--services/strategy-engine/strategies/vwap_strategy.py2
-rw-r--r--services/strategy-engine/tests/test_bollinger_strategy.py42
-rw-r--r--services/strategy-engine/tests/test_ema_crossover_strategy.py60
-rw-r--r--services/strategy-engine/tests/test_macd_strategy.py4
10 files changed, 292 insertions, 133 deletions
diff --git a/services/order-executor/src/order_executor/risk_manager.py b/services/order-executor/src/order_executor/risk_manager.py
index 94d15c2..5a05746 100644
--- a/services/order-executor/src/order_executor/risk_manager.py
+++ b/services/order-executor/src/order_executor/risk_manager.py
@@ -212,8 +212,16 @@ class RiskManager:
prices_a = prices_a[-min_len:]
prices_b = prices_b[-min_len:]
- returns_a = [(prices_a[i] - prices_a[i-1]) / prices_a[i-1] for i in range(1, len(prices_a)) if prices_a[i-1] != 0]
- returns_b = [(prices_b[i] - prices_b[i-1]) / prices_b[i-1] for i in range(1, len(prices_b)) if prices_b[i-1] != 0]
+ returns_a = [
+ (prices_a[i] - prices_a[i - 1]) / prices_a[i - 1]
+ for i in range(1, len(prices_a))
+ if prices_a[i - 1] != 0
+ ]
+ returns_b = [
+ (prices_b[i] - prices_b[i - 1]) / prices_b[i - 1]
+ for i in range(1, len(prices_b))
+ if prices_b[i - 1] != 0
+ ]
if len(returns_a) < 3 or len(returns_b) < 3:
return None
@@ -225,7 +233,9 @@ class RiskManager:
mean_a = sum(returns_a) / len(returns_a)
mean_b = sum(returns_b) / len(returns_b)
- cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len(returns_a)
+ cov = sum((a - mean_a) * (b - mean_b) for a, b in zip(returns_a, returns_b)) / len(
+ returns_a
+ )
std_a = math.sqrt(sum((a - mean_a) ** 2 for a in returns_a) / len(returns_a))
std_b = math.sqrt(sum((b - mean_b) ** 2 for b in returns_b) / len(returns_b))
@@ -253,7 +263,11 @@ class RiskManager:
if not hist or len(hist) < 5:
continue
prices = list(hist)
- returns = [(prices[i] - prices[i-1]) / prices[i-1] for i in range(1, len(prices)) if prices[i-1] != 0]
+ returns = [
+ (prices[i] - prices[i - 1]) / prices[i - 1]
+ for i in range(1, len(prices))
+ if prices[i - 1] != 0
+ ]
if returns:
all_returns.append(returns)
weight = float(pos.quantity * pos.current_price / balance)
@@ -280,15 +294,15 @@ class RiskManager:
return abs(var_return) * 100 # As percentage
- def check_portfolio_exposure(self, positions: dict[str, Position], balance: Decimal) -> RiskCheckResult:
+ def check_portfolio_exposure(
+ self, positions: dict[str, Position], balance: Decimal
+ ) -> RiskCheckResult:
"""Check total portfolio exposure."""
if balance <= 0:
return RiskCheckResult(allowed=True, reason="OK")
total_exposure = sum(
- pos.quantity * pos.current_price
- for pos in positions.values()
- if pos.quantity > 0
+ pos.quantity * pos.current_price for pos in positions.values() if pos.quantity > 0
)
exposure_ratio = total_exposure / balance
diff --git a/services/order-executor/tests/test_risk_manager.py b/services/order-executor/tests/test_risk_manager.py
index a8fe37f..00a9ab4 100644
--- a/services/order-executor/tests/test_risk_manager.py
+++ b/services/order-executor/tests/test_risk_manager.py
@@ -204,21 +204,49 @@ def test_position_size_without_scaling():
def test_portfolio_exposure_check_passes():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.8)
- positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("0.01"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))}
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ max_portfolio_exposure=0.8,
+ )
+ positions = {
+ "BTCUSDT": Position(
+ symbol="BTCUSDT",
+ quantity=Decimal("0.01"),
+ avg_entry_price=Decimal("50000"),
+ current_price=Decimal("50000"),
+ )
+ }
result = rm.check_portfolio_exposure(positions, Decimal("10000"))
assert result.allowed # 500/10000 = 5% < 80%
def test_portfolio_exposure_check_rejects():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_portfolio_exposure=0.3)
- positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("50000"), current_price=Decimal("50000"))}
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ max_portfolio_exposure=0.3,
+ )
+ positions = {
+ "BTCUSDT": Position(
+ symbol="BTCUSDT",
+ quantity=Decimal("1"),
+ avg_entry_price=Decimal("50000"),
+ current_price=Decimal("50000"),
+ )
+ }
result = rm.check_portfolio_exposure(positions, Decimal("10000"))
assert not result.allowed # 50000/10000 = 500% > 30%
def test_correlation_calculation():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"))
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ )
# Feed identical price histories — correlation should be ~1.0
for i in range(20):
rm.update_price("A", Decimal(str(100 + i)))
@@ -229,10 +257,21 @@ def test_correlation_calculation():
def test_var_calculation():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"))
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ )
for i in range(30):
rm.update_price("BTCUSDT", Decimal(str(100 + (i % 5) - 2)))
- positions = {"BTCUSDT": Position(symbol="BTCUSDT", quantity=Decimal("1"), avg_entry_price=Decimal("100"), current_price=Decimal("100"))}
+ positions = {
+ "BTCUSDT": Position(
+ symbol="BTCUSDT",
+ quantity=Decimal("1"),
+ avg_entry_price=Decimal("100"),
+ current_price=Decimal("100"),
+ )
+ }
var = rm.calculate_portfolio_var(positions, Decimal("10000"))
assert var >= 0 # Non-negative
@@ -241,28 +280,52 @@ def test_var_calculation():
def test_drawdown_position_scale_full():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ drawdown_reduction_threshold=0.1,
+ drawdown_halt_threshold=0.2,
+ )
rm.update_balance(Decimal("10000"))
scale = rm.get_position_scale(Decimal("10000"))
assert scale == 1.0 # No drawdown
def test_drawdown_position_scale_reduced():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ drawdown_reduction_threshold=0.1,
+ drawdown_halt_threshold=0.2,
+ )
rm.update_balance(Decimal("10000"))
scale = rm.get_position_scale(Decimal("8500")) # 15% drawdown (between 10% and 20%)
assert 0.25 < scale < 1.0
def test_drawdown_halt():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_reduction_threshold=0.1, drawdown_halt_threshold=0.2)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ drawdown_reduction_threshold=0.1,
+ drawdown_halt_threshold=0.2,
+ )
rm.update_balance(Decimal("10000"))
scale = rm.get_position_scale(Decimal("7500")) # 25% drawdown
assert scale == 0.0
def test_consecutive_losses_pause():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3, loss_pause_minutes=60)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ max_consecutive_losses=3,
+ loss_pause_minutes=60,
+ )
rm.record_trade_result(False)
rm.record_trade_result(False)
assert not rm.is_paused()
@@ -271,7 +334,12 @@ def test_consecutive_losses_pause():
def test_consecutive_losses_reset_on_win():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), max_consecutive_losses=3)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ max_consecutive_losses=3,
+ )
rm.record_trade_result(False)
rm.record_trade_result(False)
rm.record_trade_result(True) # Win resets counter
@@ -280,9 +348,21 @@ def test_consecutive_losses_reset_on_win():
def test_drawdown_check_rejects_in_check():
- rm = RiskManager(max_position_size=Decimal("0.5"), stop_loss_pct=Decimal("5"), daily_loss_limit_pct=Decimal("10"), drawdown_halt_threshold=0.15)
+ rm = RiskManager(
+ max_position_size=Decimal("0.5"),
+ stop_loss_pct=Decimal("5"),
+ daily_loss_limit_pct=Decimal("10"),
+ drawdown_halt_threshold=0.15,
+ )
rm.update_balance(Decimal("10000"))
- signal = Signal(strategy="test", symbol="BTC/USDT", side=OrderSide.BUY, price=Decimal("50000"), quantity=Decimal("0.01"), reason="test")
+ signal = Signal(
+ strategy="test",
+ symbol="BTC/USDT",
+ side=OrderSide.BUY,
+ price=Decimal("50000"),
+ quantity=Decimal("0.01"),
+ reason="test",
+ )
result = rm.check(signal, Decimal("8000"), {}, Decimal("0")) # 20% dd > 15%
assert not result.allowed
assert "halted" in result.reason.lower()
diff --git a/services/strategy-engine/strategies/asian_session_rsi.py b/services/strategy-engine/strategies/asian_session_rsi.py
index f22c3eb..741cd63 100644
--- a/services/strategy-engine/strategies/asian_session_rsi.py
+++ b/services/strategy-engine/strategies/asian_session_rsi.py
@@ -2,9 +2,10 @@
규칙:
- SOL/USDT 5분봉
-- 매수: RSI(14) < 25 + 볼륨 > 평균
+- 매수: RSI(14) < 25 + 볼륨 > 평균 + 센티먼트 OK
- 익절: +1.5%, 손절: -0.7%, 시간청산: 11:00 KST (02:00 UTC)
- 하루 최대 3회, 2연패 시 중단
+- 센티먼트 필터: Fear & Greed > 80이면 매수 차단, 뉴스 극도 부정이면 차단
"""
from collections import deque
@@ -14,6 +15,7 @@ from datetime import datetime
import pandas as pd
from shared.models import Candle, Signal, OrderSide
+from shared.sentiment import SentimentData
from strategies.base import BaseStrategy
@@ -33,6 +35,9 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._session_end_utc: int = 2
self._max_trades_per_day: int = 3
self._max_consecutive_losses: int = 2
+ self._use_sentiment: bool = True
+ # Sentiment (updated externally before each session)
+ self._sentiment: SentimentData | None = None
# State
self._closes: deque[float] = deque(maxlen=200)
self._volumes: deque[float] = deque(maxlen=50)
@@ -57,6 +62,7 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._session_end_utc = int(params.get("session_end_utc", 2))
self._max_trades_per_day = int(params.get("max_trades_per_day", 3))
self._max_consecutive_losses = int(params.get("max_consecutive_losses", 2))
+ self._use_sentiment = bool(params.get("use_sentiment", True))
if self._quantity <= 0:
raise ValueError(f"Quantity must be positive, got {self._quantity}")
@@ -82,6 +88,17 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._consecutive_losses = 0
self._in_position = False
self._entry_price = 0.0
+ self._sentiment = None
+
+ def update_sentiment(self, sentiment: SentimentData) -> None:
+ """Update sentiment data. Call before each trading session."""
+ self._sentiment = sentiment
+
+ def _check_sentiment(self) -> bool:
+ """Check if sentiment allows buying. Returns True if OK."""
+ if not self._use_sentiment or self._sentiment is None:
+ return True # No sentiment data, allow by default
+ return not self._sentiment.should_block
def _is_session_active(self, dt: datetime) -> bool:
"""Check if current time is within trading session."""
@@ -135,29 +152,33 @@ class AsianSessionRsiStrategy(BaseStrategy):
if pnl_pct >= self._take_profit_pct:
self._in_position = False
self._consecutive_losses = 0
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=0.9,
- reason=f"Take profit {pnl_pct:.2f}% >= {self._take_profit_pct}%",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.9,
+ reason=f"Take profit {pnl_pct:.2f}% >= {self._take_profit_pct}%",
+ )
+ )
# Stop loss
if pnl_pct <= -self._stop_loss_pct:
self._in_position = False
self._consecutive_losses += 1
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=1.0,
- reason=f"Stop loss {pnl_pct:.2f}% <= -{self._stop_loss_pct}%",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=1.0,
+ reason=f"Stop loss {pnl_pct:.2f}% <= -{self._stop_loss_pct}%",
+ )
+ )
# Time exit: session ended while in position
if not self._is_session_active(candle.open_time):
@@ -166,15 +187,17 @@ class AsianSessionRsiStrategy(BaseStrategy):
self._consecutive_losses += 1
else:
self._consecutive_losses = 0
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=0.5,
- reason=f"Time exit (session ended), PnL {pnl_pct:.2f}%",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.5,
+ reason=f"Time exit (session ended), PnL {pnl_pct:.2f}%",
+ )
+ )
return None # Still in position, no action
@@ -188,6 +211,9 @@ class AsianSessionRsiStrategy(BaseStrategy):
if self._consecutive_losses >= self._max_consecutive_losses:
return None # Consecutive loss limit
+ if not self._check_sentiment():
+ return None # Sentiment blocked (extreme greed or very negative news)
+
rsi = self._compute_rsi()
if rsi is None:
return None
@@ -204,16 +230,18 @@ class AsianSessionRsiStrategy(BaseStrategy):
sl = candle.close * (1 - Decimal(str(self._stop_loss_pct / 100)))
tp = candle.close * (1 + Decimal(str(self._take_profit_pct / 100)))
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.BUY,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- stop_loss=sl,
- take_profit=tp,
- reason=f"RSI {rsi:.1f} < {self._rsi_oversold} (session active, vol OK)",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ stop_loss=sl,
+ take_profit=tp,
+ reason=f"RSI {rsi:.1f} < {self._rsi_oversold} (session active, vol OK)",
+ )
+ )
return None
diff --git a/services/strategy-engine/strategies/bollinger_strategy.py b/services/strategy-engine/strategies/bollinger_strategy.py
index a195cb8..ebe7967 100644
--- a/services/strategy-engine/strategies/bollinger_strategy.py
+++ b/services/strategy-engine/strategies/bollinger_strategy.py
@@ -102,27 +102,31 @@ class BollingerStrategy(BaseStrategy):
if price > sma:
# Breakout upward
conv = min(0.5 + squeeze_duration * 0.1, 1.0)
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.BUY,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.BUY,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout UP after {squeeze_duration} bars",
+ )
+ )
else:
# Breakout downward
conv = min(0.5 + squeeze_duration * 0.1, 1.0)
- return self._apply_filters(Signal(
- strategy=self.name,
- symbol=candle.symbol,
- side=OrderSide.SELL,
- price=candle.close,
- quantity=self._quantity,
- conviction=conv,
- reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=conv,
+ reason=f"Bollinger squeeze breakout DOWN after {squeeze_duration} bars",
+ )
+ )
# Bandwidth filter: skip sideways markets
if sma != 0 and bandwidth < self._min_bandwidth:
diff --git a/services/strategy-engine/strategies/combined_strategy.py b/services/strategy-engine/strategies/combined_strategy.py
index 907d9c5..ba92485 100644
--- a/services/strategy-engine/strategies/combined_strategy.py
+++ b/services/strategy-engine/strategies/combined_strategy.py
@@ -53,7 +53,9 @@ class CombinedStrategy(BaseStrategy):
self._trade_history[strategy_name].append(is_win)
# Keep only last N results
if len(self._trade_history[strategy_name]) > self._history_window:
- self._trade_history[strategy_name] = self._trade_history[strategy_name][-self._history_window:]
+ self._trade_history[strategy_name] = self._trade_history[strategy_name][
+ -self._history_window :
+ ]
def _get_adaptive_weight(self, strategy_name: str, base_weight: float) -> float:
"""Get weight adjusted by recent performance."""
@@ -90,10 +92,14 @@ class CombinedStrategy(BaseStrategy):
effective_weight = self._get_adaptive_weight(strategy.name, weight)
if signal.side == OrderSide.BUY:
score += effective_weight * signal.conviction
- reasons.append(f"{strategy.name}:BUY({effective_weight}*{signal.conviction:.2f})")
+ reasons.append(
+ f"{strategy.name}:BUY({effective_weight}*{signal.conviction:.2f})"
+ )
elif signal.side == OrderSide.SELL:
score -= effective_weight * signal.conviction
- reasons.append(f"{strategy.name}:SELL({effective_weight}*{signal.conviction:.2f})")
+ reasons.append(
+ f"{strategy.name}:SELL({effective_weight}*{signal.conviction:.2f})"
+ )
normalized = score / total_weight # Range: -1.0 to 1.0
diff --git a/services/strategy-engine/strategies/grid_strategy.py b/services/strategy-engine/strategies/grid_strategy.py
index 07ccaba..283bfe5 100644
--- a/services/strategy-engine/strategies/grid_strategy.py
+++ b/services/strategy-engine/strategies/grid_strategy.py
@@ -40,9 +40,7 @@ class GridStrategy(BaseStrategy):
f"got lower={self._lower_price}, upper={self._upper_price}"
)
if self._exit_threshold_pct <= 0:
- raise ValueError(
- f"exit_threshold_pct must be > 0, got {self._exit_threshold_pct}"
- )
+ raise ValueError(f"exit_threshold_pct must be > 0, got {self._exit_threshold_pct}")
if self._grid_count < 2:
raise ValueError(f"Grid grid_count must be >= 2, got {self._grid_count}")
if self._quantity <= 0:
@@ -90,12 +88,17 @@ class GridStrategy(BaseStrategy):
if not self._out_of_range:
self._out_of_range = True
# Exit signal — close positions
- return self._apply_filters(Signal(
- strategy=self.name, symbol=candle.symbol,
- side=OrderSide.SELL, price=candle.close,
- quantity=self._quantity, conviction=0.8,
- reason=f"Grid: price {price:.2f} broke out of range [{self._grid_levels[0]:.2f}, {self._grid_levels[-1]:.2f}]",
- ))
+ return self._apply_filters(
+ Signal(
+ strategy=self.name,
+ symbol=candle.symbol,
+ side=OrderSide.SELL,
+ price=candle.close,
+ quantity=self._quantity,
+ conviction=0.8,
+ reason=f"Grid: price {price:.2f} broke out of range [{self._grid_levels[0]:.2f}, {self._grid_levels[-1]:.2f}]",
+ )
+ )
return None # Already out of range, no more signals
else:
self._out_of_range = False
diff --git a/services/strategy-engine/strategies/vwap_strategy.py b/services/strategy-engine/strategies/vwap_strategy.py
index 0348752..d64950e 100644
--- a/services/strategy-engine/strategies/vwap_strategy.py
+++ b/services/strategy-engine/strategies/vwap_strategy.py
@@ -110,7 +110,7 @@ class VwapStrategy(BaseStrategy):
diffs = [tp - v for tp, v in zip(self._tp_values, self._vwap_values)]
mean_diff = sum(diffs) / len(diffs)
variance = sum((d - mean_diff) ** 2 for d in diffs) / len(diffs)
- std_dev = variance ** 0.5
+ std_dev = variance**0.5
deviation = (close - vwap) / vwap
diff --git a/services/strategy-engine/tests/test_bollinger_strategy.py b/services/strategy-engine/tests/test_bollinger_strategy.py
index 473d9b4..7761f2d 100644
--- a/services/strategy-engine/tests/test_bollinger_strategy.py
+++ b/services/strategy-engine/tests/test_bollinger_strategy.py
@@ -107,12 +107,14 @@ def test_bollinger_squeeze_detection():
"""Tight bandwidth → no signal during squeeze."""
# Use a strategy with a high squeeze threshold so constant prices trigger squeeze
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 2.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.5, # Very high threshold to ensure squeeze triggers
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 2.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.5, # Very high threshold to ensure squeeze triggers
+ }
+ )
# Feed identical prices → bandwidth = 0 (below any threshold)
for _ in range(6):
@@ -126,12 +128,14 @@ def test_bollinger_squeeze_detection():
def test_bollinger_squeeze_breakout_buy():
"""Squeeze ends with price above SMA → BUY signal."""
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 1.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.01,
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 1.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.01,
+ }
+ )
# Feed identical prices to create a squeeze (bandwidth = 0)
for _ in range(6):
@@ -149,12 +153,14 @@ def test_bollinger_squeeze_breakout_buy():
def test_bollinger_pct_b_conviction():
"""Signals near band extremes have higher conviction via %B."""
s = BollingerStrategy()
- s.configure({
- "period": 5,
- "num_std": 1.0,
- "min_bandwidth": 0.0,
- "squeeze_threshold": 0.0, # Disable squeeze for this test
- })
+ s.configure(
+ {
+ "period": 5,
+ "num_std": 1.0,
+ "min_bandwidth": 0.0,
+ "squeeze_threshold": 0.0, # Disable squeeze for this test
+ }
+ )
# Build up with stable prices
for _ in range(5):
diff --git a/services/strategy-engine/tests/test_ema_crossover_strategy.py b/services/strategy-engine/tests/test_ema_crossover_strategy.py
index 9e48478..67a20bf 100644
--- a/services/strategy-engine/tests/test_ema_crossover_strategy.py
+++ b/services/strategy-engine/tests/test_ema_crossover_strategy.py
@@ -21,9 +21,18 @@ def make_candle(close: float) -> Candle:
)
-def _make_strategy(short: int = 3, long: int = 6, pullback_enabled: bool = False) -> EmaCrossoverStrategy:
+def _make_strategy(
+ short: int = 3, long: int = 6, pullback_enabled: bool = False
+) -> EmaCrossoverStrategy:
s = EmaCrossoverStrategy()
- s.configure({"short_period": short, "long_period": long, "quantity": "0.01", "pullback_enabled": pullback_enabled})
+ s.configure(
+ {
+ "short_period": short,
+ "long_period": long,
+ "quantity": "0.01",
+ "pullback_enabled": pullback_enabled,
+ }
+ )
return s
@@ -103,13 +112,15 @@ def test_ema_reset_clears_state():
def test_ema_pullback_entry():
"""Crossover detected, then pullback to short EMA triggers signal."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": True,
- "pullback_tolerance": 0.05, # 5% tolerance for test simplicity
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.05, # 5% tolerance for test simplicity
+ }
+ )
# Declining prices so short EMA stays below long EMA
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
@@ -129,6 +140,7 @@ def test_ema_pullback_entry():
# The short EMA will be tracking recent prices; feed a price that pulls back
# toward it. We use a moderate price to get close to short EMA.
import pandas as pd
+
series = pd.Series(list(strategy._closes))
short_ema_val = series.ewm(span=3, adjust=False).mean().iloc[-1]
# Feed a candle at approximately the short EMA value
@@ -141,13 +153,15 @@ def test_ema_pullback_entry():
def test_ema_pullback_cancelled_on_reversal():
"""Crossover detected, then reversal cancels the pending signal."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": True,
- "pullback_tolerance": 0.001, # Very tight tolerance — won't trigger easily
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": True,
+ "pullback_tolerance": 0.001, # Very tight tolerance — won't trigger easily
+ }
+ )
# Declining prices
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
@@ -172,12 +186,14 @@ def test_ema_pullback_cancelled_on_reversal():
def test_ema_immediate_mode():
"""With pullback_enabled=False, original immediate entry works."""
strategy = EmaCrossoverStrategy()
- strategy.configure({
- "short_period": 3,
- "long_period": 6,
- "quantity": "0.01",
- "pullback_enabled": False,
- })
+ strategy.configure(
+ {
+ "short_period": 3,
+ "long_period": 6,
+ "quantity": "0.01",
+ "pullback_enabled": False,
+ }
+ )
# Declining prices so short EMA stays below long EMA
declining = [100, 98, 96, 94, 92, 90, 88, 86, 84, 82]
diff --git a/services/strategy-engine/tests/test_macd_strategy.py b/services/strategy-engine/tests/test_macd_strategy.py
index cd24ee0..17dd2cf 100644
--- a/services/strategy-engine/tests/test_macd_strategy.py
+++ b/services/strategy-engine/tests/test_macd_strategy.py
@@ -98,7 +98,9 @@ def test_macd_signal_line_crossover():
assert len(buy_signals) > 0, "Expected at least one BUY signal"
# Check that at least one is a signal-line crossover or histogram crossover
all_reasons = [sig.reason for sig in buy_signals]
- assert any("crossover" in r for r in all_reasons), f"Expected crossover signal, got: {all_reasons}"
+ assert any("crossover" in r for r in all_reasons), (
+ f"Expected crossover signal, got: {all_reasons}"
+ )
def test_macd_conviction_varies_with_distance():