from __future__ import annotations from typing import ClassVar import pytest from app.broker.paper import PaperBroker from app.config import Settings from app.engine.engine import TradingEngine from app.engine.risk import RiskManager # noqa: F401 (documents the dependency) from app.engine.strategies.base import Strategy, StrategyContext from app.market.fake import FakeMarketData from app.models import OrderSide, PositionSide from app.schemas import Candle from app.store import Store class ToggleStrategy(Strategy): key: ClassVar[str] = "test_toggle" name: ClassVar[str] = "Toggle" default_params: ClassVar[dict] = {} def on_candle(self, ctx: StrategyContext) -> None: if ctx.is_flat: ctx.enter_long(size=0.5) else: ctx.close() def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine: return TradingEngine( feed=market, broker=PaperBroker(fee_bps=0, slippage_bps=0), store=store, settings=settings, ) async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None: market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test") assert engine.position is not None assert engine.cash == pytest.approx(9_900.0) assert engine.equity == pytest.approx(10_000.0) engine.last_price = 110.0 assert engine.equity == pytest.approx(10_010.0) assert engine.unrealized_pnl == pytest.approx(10.0) await engine.close_current_position() assert engine.position is None assert engine.realized_pnl == pytest.approx(10.0) assert engine.cash == pytest.approx(10_010.0) async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None: market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 await engine._open_position( PositionSide.LONG, size=0.0, stop_loss=95.0, take_profit=120.0, reason="entry", qty_override=1.0, ) await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1)) assert engine.position is None assert engine.realized_pnl == pytest.approx(-5.0) async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None: market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 await engine._open_position( PositionSide.LONG, size=0.0, stop_loss=95.0, take_profit=110.0, reason="entry", qty_override=1.0, ) await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1)) assert engine.position is None assert engine.realized_pnl == pytest.approx(10.0) async def test_daily_loss_halts_and_flattens(settings: Settings, store: Store) -> None: settings.max_daily_loss = 50.0 market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 engine.risk.reset(10_000) await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test") market.candles[0] = Candle(ts=0, open=100, high=100, low=40, close=40, volume=1) await engine.tick() assert engine.risk.halted is True assert engine.position is None assert engine.running is False async def test_kill_switch_flattens_and_stops(settings: Settings, store: Store) -> None: market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test") assert engine.position is not None await engine.kill("panic") assert engine.running is False assert engine.risk.halted is True assert engine.position is None assert engine.status().halt_reason == "panic" async def test_max_trades_per_day_blocks_new_entries(settings: Settings, store: Store) -> None: settings.max_trades_per_day = 1 market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 engine.risk.reset(10_000) await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="first") assert engine.position is not None await engine.close_current_position() await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="second") assert engine.position is None async def test_max_position_pct_clamps_size(settings: Settings, store: Store) -> None: settings.max_position_pct = 0.5 market = FakeMarketData.from_closes([100.0]) engine = build_engine(settings, store, market) engine.symbol = "BTC/USDT" engine.last_price = 100.0 engine.risk.reset(10_000) await engine._open_position( PositionSide.LONG, size=1.0, stop_loss=None, take_profit=None, reason="big" ) assert engine.position is not None assert engine.position.qty == pytest.approx(50.0) async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None: market = FakeMarketData.from_closes([100.0, 100.0]) engine = build_engine(settings, store, market) engine.strategy = ToggleStrategy() engine.strategy_key = "test_toggle" engine.symbol = "BTC/USDT" await engine.tick() assert engine.position is not None assert engine.position.side == PositionSide.LONG market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1)) await engine.tick() assert engine.position is None