from __future__ import annotations from collections.abc import Sequence from app.engine.strategies.base import StrategyContext from app.schemas import Candle def make_candles(closes: Sequence[float]) -> list[Candle]: return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)] def context(closes: Sequence[float], position=None) -> StrategyContext: candles = make_candles(closes) return StrategyContext(candles=candles, position=position, equity=1000.0, cash=1000.0) def test_all_strategies_discovered() -> None: from app.engine.strategies import list_strategies keys = {cls.key for cls in list_strategies()} assert { "sma_cross", "rsi_reversion", "macd_trend", "bollinger_reversion", "donchian_breakout", "ema_cross", "momentum", } <= keys def test_every_strategy_has_spec_and_params() -> None: from app.engine.strategies import list_strategies, strategy_spec for cls in list_strategies(): spec = strategy_spec(cls) assert spec.key and spec.name and spec.description # Defaults must round-trip through the constructor and be constructible. instance = cls({}) assert instance.key == cls.key # A short synthetic series must never raise for a registered strategy. ctx = context([100, 101, 100, 102, 99, 103, 98, 104, 97, 105]) instance.on_candle(ctx) def test_ema_cross_enters_on_cross_up() -> None: from app.engine.strategies import get_strategy strategy = get_strategy("ema_cross")({"fast": 2, "slow": 4, "trend_filter": 0, "size": 0.5}) # Fast EMA dips below the slow EMA, then crosses back above. ctx = context([10, 10, 10, 10, 10, 10, 10, 10, 7, 9, 12]) strategy.on_candle(ctx) intents = ctx.take_intents() assert intents and intents[0].action == "enter_long" def test_momentum_enters_on_positive_roc() -> None: from app.engine.strategies import get_strategy strategy = get_strategy("momentum")({"period": 3, "entry_threshold": 0.01, "atr_stop_mult": 0}) ctx = context([100, 100, 100, 105]) strategy.on_candle(ctx) intents = ctx.take_intents() assert intents and intents[0].action == "enter_long" def test_macd_trend_no_intent_when_flat_short_history() -> None: from app.engine.strategies import get_strategy strategy = get_strategy("macd_trend")({}) ctx = context([100, 101, 102]) strategy.on_candle(ctx) assert ctx.take_intents() == [] def test_bollinger_enters_below_lower_band() -> None: from app.engine.strategies import get_strategy strategy = get_strategy("bollinger_reversion")( {"period": 5, "num_std": 1.0, "atr_stop_mult": 0} ) # Flat then a sharp drop below the lower band. ctx = context([100, 100, 100, 100, 80]) strategy.on_candle(ctx) intents = ctx.take_intents() assert intents and intents[0].action == "enter_long" def test_donchian_enters_on_breakout() -> None: from app.engine.strategies import get_strategy strategy = get_strategy("donchian_breakout")( {"entry_period": 5, "exit_period": 3, "atr_stop_mult": 0} ) ctx = context([100, 100, 100, 100, 100, 110]) strategy.on_candle(ctx) intents = ctx.take_intents() assert intents and intents[0].action == "enter_long"