from __future__ import annotations import pytest from app.engine.manager import MultiStrategyManager, normalise_weights from app.runtime import Runtime from app.schemas import StartManyRequest, StrategySpec def test_normalise_weights() -> None: specs = [StrategySpec(strategy_key="a", weight=1), StrategySpec(strategy_key="b", weight=3)] assert normalise_weights(specs) == [0.25, 0.75] def test_normalise_weights_single() -> None: assert normalise_weights([StrategySpec(strategy_key="a")]) == [1.0] def test_weights_must_be_positive() -> None: from pydantic import ValidationError with pytest.raises(ValidationError): StrategySpec(strategy_key="a", weight=0) def test_normalise_weights_guards_zero_total() -> None: # The API rejects non-positive weights, but the helper still guards against # a zero total (e.g. if it is ever called with raw values). class Dummy: def __init__(self, weight: float) -> None: self.weight = weight assert normalise_weights([Dummy(0), Dummy(0)]) == [0.5, 0.5] async def test_start_many_seeds_each_engine_with_its_allocation(settings, store, market) -> None: runtime = Runtime( settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper() ) status = await runtime.start_many( StartManyRequest( strategies=[ StrategySpec(strategy_key="sma_cross", params={"fast": 2, "slow": 4}, weight=1), StrategySpec(strategy_key="ema_cross", params={"fast": 2, "slow": 4}, weight=3), ], starting_balance=10_000, ) ) assert status.running is True assert [s.allocation for s in status.strategies] == pytest.approx([0.25, 0.75]) # Each engine starts with its slice of the balance as cash. assert [s.cash for s in status.strategies] == pytest.approx([2_500, 7_500]) assert status.starting_balance == pytest.approx(10_000) assert status.equity == pytest.approx(10_000) await runtime.shutdown() async def test_strategies_are_isolated(settings, store, market) -> None: """A loss in one strategy must not touch another's cash.""" runtime = Runtime( settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper() ) await runtime.start_many( StartManyRequest( strategies=[ StrategySpec(strategy_key="sma_cross", weight=1), StrategySpec(strategy_key="ema_cross", weight=1), ], starting_balance=10_000, ) ) first, second = runtime.manager.runs[0].engine, runtime.manager.runs[1].engine first.last_price = 100.0 second.last_price = 100.0 await first.manual_order(_buy(), qty=10, quote_amount=None, reason="lose") first.last_price = 90.0 await first.close_current_position("loss") assert first.cash == pytest.approx(4_900.0) assert second.cash == pytest.approx(5_000.0) await runtime.shutdown() async def test_stop_clears_runs(settings, store, market) -> None: runtime = Runtime( settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper() ) await runtime.start_many(StartManyRequest(strategies=[StrategySpec(strategy_key="sma_cross")])) await runtime.stop() assert runtime.multi_status().running is False assert runtime.multi_status().strategies == [] await runtime.shutdown() async def test_duplicate_strategy_keys_are_allowed(settings, store, market) -> None: runtime = Runtime( settings, store, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper() ) status = await runtime.start_many( StartManyRequest( strategies=[ StrategySpec(strategy_key="sma_cross", params={"fast": 2, "slow": 10}), StrategySpec(strategy_key="sma_cross", params={"fast": 5, "slow": 20}), ] ) ) assert len(status.strategies) == 2 assert status.strategies[0].params["fast"] == 2 assert status.strategies[1].params["fast"] == 5 await runtime.shutdown() async def test_bad_params_rejected_before_starting(settings, store, market) -> None: manager = MultiStrategyManager( store=store, settings=settings, feed_factory=lambda _s: market, broker_factory=lambda _s: _paper(), ) with pytest.raises(ValueError): await manager.start( StartManyRequest( strategies=[StrategySpec(strategy_key="sma_cross", params={"nope": 1})] ) ) assert manager.runs == [] def _paper(): from app.broker.paper import PaperBroker return PaperBroker(fee_bps=0, slippage_bps=0) def _buy(): from app.models import OrderSide return OrderSide.BUY