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.
This commit is contained in:
1 parent
e07536542f
commit
8b6cf8130c
6 files changed
+469
-28
No files matched your search
@@ -0,0 +1,139 @@
|
||||
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
|
||||
Reference in new issue
Block a user