Files
matitos 8b6cf8130c Multi-strategy engine with per-strategy capital allocation
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.
2026-09-21 20:10:28 +00:00

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