Adds MultiStrategyManager: it runs several strategies concurrently, each with its own TradingEngine seeded with starting_balance * allocation, so one strategy's losses cannot eat into another's capital. Weights are normalised across the run ([1,3] -> 25%/75%). Risk limits stay per strategy (each engine owns a RiskManager). New API: POST /api/bot/start-many and GET /api/bot/status. The existing single-strategy endpoints delegate to the manager, so their behaviour is unchanged. Adds manager tests (allocation, isolation, duplicate keys) and API tests.
140 lines
4.7 KiB
Python
140 lines
4.7 KiB
Python
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
|