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
@@ -18,8 +18,10 @@ from app.schemas import (
|
||||
EventOut,
|
||||
KillRequest,
|
||||
ManualOrderRequest,
|
||||
MultiStatusOut,
|
||||
OrderOut,
|
||||
PositionOut,
|
||||
StartManyRequest,
|
||||
StartRequest,
|
||||
StatusOut,
|
||||
StrategyConfigIn,
|
||||
@@ -96,6 +98,21 @@ async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> Status
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/bot/status", response_model=MultiStatusOut)
|
||||
def multi_status(runtime: GuardedRuntimeDep) -> MultiStatusOut:
|
||||
return runtime.multi_status()
|
||||
|
||||
|
||||
@router.post("/bot/start-many", response_model=MultiStatusOut)
|
||||
async def start_many(payload: StartManyRequest, runtime: GuardedRuntimeDep) -> MultiStatusOut:
|
||||
try:
|
||||
return await runtime.start_many(payload)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/bot/stop", response_model=StatusOut)
|
||||
async def stop_bot(runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return await runtime.stop()
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.broker import build_broker
|
||||
from app.broker.base import Broker
|
||||
from app.config import Settings
|
||||
from app.engine.engine import Publish, TradingEngine
|
||||
from app.engine.strategies import get_strategy
|
||||
from app.market import build_market_data
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import (
|
||||
MultiStatusOut,
|
||||
StartManyRequest,
|
||||
StrategySpec,
|
||||
StrategyStatusOut,
|
||||
)
|
||||
from app.store import Store
|
||||
|
||||
FeedFactory = Callable[[Settings], MarketData]
|
||||
BrokerFactory = Callable[[Settings], Broker]
|
||||
|
||||
|
||||
def normalise_weights(specs: list[StrategySpec]) -> list[float]:
|
||||
"""Turn relative weights into allocations that sum to exactly 1.0."""
|
||||
total = sum(max(0.0, spec.weight) for spec in specs)
|
||||
if total <= 0:
|
||||
return [1 / len(specs)] * len(specs)
|
||||
return [max(0.0, spec.weight) / total for spec in specs]
|
||||
|
||||
|
||||
class StrategyRun:
|
||||
"""One strategy's engine plus the allocation it was given."""
|
||||
|
||||
def __init__(self, spec: StrategySpec, engine: TradingEngine, allocation: float) -> None:
|
||||
self.spec = spec
|
||||
self.engine = engine
|
||||
self.allocation = allocation
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return self.spec.strategy_key
|
||||
|
||||
|
||||
class MultiStrategyManager:
|
||||
"""Runs several strategies concurrently, each on its own capital slice.
|
||||
|
||||
Every strategy gets an independent :class:`TradingEngine` seeded with
|
||||
``starting_balance * allocation``, so one strategy's losses cannot eat into
|
||||
another's capital. Risk limits apply per strategy (each engine owns its own
|
||||
RiskManager); the manager aggregates status and can halt everything at once.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: Store,
|
||||
settings: Settings,
|
||||
feed_factory: FeedFactory = build_market_data,
|
||||
broker_factory: BrokerFactory = build_broker,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.settings = settings
|
||||
self._feed_factory = feed_factory
|
||||
self._broker_factory = broker_factory
|
||||
self.runs: list[StrategyRun] = []
|
||||
self.starting_balance = settings.starting_balance
|
||||
self._publish_cb: Publish | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def set_publisher(self, publish: Publish | None) -> None:
|
||||
self._publish_cb = publish
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return bool(self.runs) and any(run.engine.running for run in self.runs)
|
||||
|
||||
@property
|
||||
def last_price(self) -> float | None:
|
||||
for run in self.runs:
|
||||
if run.engine.last_price is not None:
|
||||
return run.engine.last_price
|
||||
return None
|
||||
|
||||
async def start(self, request: StartManyRequest) -> MultiStatusOut:
|
||||
await self.stop()
|
||||
|
||||
starting_balance = float(request.starting_balance or self.settings.starting_balance)
|
||||
allocations = normalise_weights(request.strategies)
|
||||
self.starting_balance = starting_balance
|
||||
self.runs = []
|
||||
|
||||
for spec, allocation in zip(request.strategies, allocations, strict=True):
|
||||
strategy_cls = get_strategy(spec.strategy_key)
|
||||
# Validate params early so a bad request fails before any orders.
|
||||
strategy_cls(spec.params)
|
||||
|
||||
engine = TradingEngine(
|
||||
feed=self._feed_factory(self.settings),
|
||||
broker=self._broker_factory(self.settings),
|
||||
store=self.store,
|
||||
settings=self.settings,
|
||||
)
|
||||
engine.set_publisher(self._publish_cb)
|
||||
await engine.start(
|
||||
spec.strategy_key,
|
||||
spec.params,
|
||||
symbol=spec.symbol,
|
||||
timeframe=spec.timeframe,
|
||||
starting_balance=starting_balance * allocation,
|
||||
)
|
||||
self.runs.append(StrategyRun(spec, engine, allocation))
|
||||
|
||||
return self.status()
|
||||
|
||||
async def stop(self) -> MultiStatusOut:
|
||||
for run in self.runs:
|
||||
await run.engine.stop()
|
||||
self.runs = []
|
||||
return self.status()
|
||||
|
||||
async def kill(self, reason: str = "manual kill switch") -> MultiStatusOut:
|
||||
for run in self.runs:
|
||||
await run.engine.kill(reason)
|
||||
return self.status()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
for run in self.runs:
|
||||
await run.engine.shutdown()
|
||||
self.runs = []
|
||||
|
||||
def status(self) -> MultiStatusOut:
|
||||
total_equity = 0.0
|
||||
per_strategy: list[StrategyStatusOut] = []
|
||||
last_tick = None
|
||||
for run in self.runs:
|
||||
engine_status = run.engine.status()
|
||||
total_equity += engine_status.equity
|
||||
if engine_status.last_tick and (
|
||||
last_tick is None or engine_status.last_tick > last_tick
|
||||
):
|
||||
last_tick = engine_status.last_tick
|
||||
per_strategy.append(
|
||||
StrategyStatusOut(
|
||||
strategy_key=engine_status.strategy_key or run.key,
|
||||
strategy_name=engine_status.strategy_name,
|
||||
params=engine_status.params,
|
||||
symbol=engine_status.symbol,
|
||||
timeframe=engine_status.timeframe,
|
||||
allocation=run.allocation,
|
||||
cash=engine_status.cash,
|
||||
equity=engine_status.equity,
|
||||
realized_pnl=engine_status.realized_pnl,
|
||||
unrealized_pnl=engine_status.unrealized_pnl,
|
||||
total_pnl=engine_status.total_pnl,
|
||||
open_positions=engine_status.open_positions,
|
||||
halted=engine_status.halted,
|
||||
halt_reason=engine_status.halt_reason,
|
||||
trades_today=engine_status.trades_today,
|
||||
error=engine_status.error,
|
||||
)
|
||||
)
|
||||
|
||||
return MultiStatusOut(
|
||||
running=self.running,
|
||||
mode=self.settings.mode,
|
||||
exchange=self.settings.exchange,
|
||||
price=self.last_price,
|
||||
starting_balance=self.starting_balance,
|
||||
equity=total_equity,
|
||||
total_pnl=total_equity - self.starting_balance,
|
||||
strategies=per_strategy,
|
||||
last_tick=last_tick,
|
||||
)
|
||||
|
||||
def engine_at(self, index: int) -> TradingEngine | None:
|
||||
if 0 <= index < len(self.runs):
|
||||
return self.runs[index].engine
|
||||
return None
|
||||
|
||||
|
||||
__all__ = ["MultiStrategyManager", "StrategyRun", "normalise_weights"]
|
||||
+44
-28
@@ -11,6 +11,7 @@ from app.broker import build_broker
|
||||
from app.broker.base import Broker
|
||||
from app.config import Settings
|
||||
from app.engine.engine import TradingEngine
|
||||
from app.engine.manager import MultiStrategyManager
|
||||
from app.engine.strategies import get_strategy
|
||||
from app.market import build_market_data
|
||||
from app.market.base import MarketData
|
||||
@@ -18,7 +19,10 @@ from app.notify import Notifier, build_notifier
|
||||
from app.schemas import (
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
MultiStatusOut,
|
||||
StartManyRequest,
|
||||
StatusOut,
|
||||
StrategySpec,
|
||||
SweepRequest,
|
||||
SweepResult,
|
||||
SweepRowOut,
|
||||
@@ -52,10 +56,22 @@ class Runtime:
|
||||
self._broker_factory = broker_factory
|
||||
self.notifier = notifier if notifier is not None else notifier_factory(settings)
|
||||
self.notify_levels = notify_levels or {"trade", "error"}
|
||||
self.engine: TradingEngine | None = None
|
||||
self.manager = MultiStrategyManager(
|
||||
store=self.store,
|
||||
settings=settings,
|
||||
feed_factory=feed_factory,
|
||||
broker_factory=broker_factory,
|
||||
)
|
||||
self.manager.set_publisher(self._broadcast)
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
self._notify_tasks: set[asyncio.Task] = set()
|
||||
|
||||
# The first (usually only) engine, kept for the single-strategy API and
|
||||
# backwards compatibility with existing callers and tests.
|
||||
@property
|
||||
def engine(self) -> TradingEngine | None:
|
||||
return self.manager.runs[0].engine if self.manager.runs else None
|
||||
|
||||
# --- pub/sub ------------------------------------------------------
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
@@ -96,36 +112,37 @@ class Runtime:
|
||||
timeframe: str | None = None,
|
||||
starting_balance: float | None = None,
|
||||
) -> StatusOut:
|
||||
if self.engine is not None and self.engine.running:
|
||||
await self.stop()
|
||||
engine = TradingEngine(
|
||||
feed=self._feed_factory(self.settings),
|
||||
broker=self._broker_factory(self.settings),
|
||||
store=self.store,
|
||||
settings=self.settings,
|
||||
await self.start_many(
|
||||
StartManyRequest(
|
||||
strategies=[
|
||||
StrategySpec(
|
||||
strategy_key=strategy_key,
|
||||
params=params or {},
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
)
|
||||
],
|
||||
starting_balance=starting_balance,
|
||||
)
|
||||
)
|
||||
engine.set_publisher(self._broadcast)
|
||||
self.engine = engine
|
||||
await engine.start(
|
||||
strategy_key,
|
||||
params,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
starting_balance=starting_balance,
|
||||
)
|
||||
return engine.status()
|
||||
engine = self.engine
|
||||
return engine.status() if engine is not None else self._idle_status()
|
||||
|
||||
async def start_many(self, request: StartManyRequest) -> MultiStatusOut:
|
||||
return await self.manager.start(request)
|
||||
|
||||
async def stop(self) -> StatusOut:
|
||||
if self.engine is not None:
|
||||
await self.engine.stop()
|
||||
return self.engine.status()
|
||||
return self._idle_status()
|
||||
await self.manager.stop()
|
||||
engine = self.engine
|
||||
return engine.status() if engine is not None else self._idle_status()
|
||||
|
||||
async def kill(self, reason: str = "manual kill switch") -> StatusOut:
|
||||
if self.engine is not None:
|
||||
await self.engine.kill(reason)
|
||||
return self.engine.status()
|
||||
return self._idle_status()
|
||||
await self.manager.kill(reason)
|
||||
engine = self.engine
|
||||
return engine.status() if engine is not None else self._idle_status()
|
||||
|
||||
def multi_status(self) -> MultiStatusOut:
|
||||
return self.manager.status()
|
||||
|
||||
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
|
||||
symbol = request.symbol or self.settings.symbol
|
||||
@@ -182,8 +199,7 @@ class Runtime:
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.engine is not None:
|
||||
await self.engine.shutdown()
|
||||
await self.manager.shutdown()
|
||||
if self.notifier is not None:
|
||||
await self.notifier.close()
|
||||
|
||||
|
||||
@@ -134,6 +134,18 @@ class StrategyConfigOut(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StrategySpec(BaseModel):
|
||||
"""One strategy in a multi-strategy run."""
|
||||
|
||||
strategy_key: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
symbol: str | None = None
|
||||
timeframe: str | None = None
|
||||
# Fraction of the account allocated to this strategy (relative weights are
|
||||
# normalised across the run, so [1, 3] gives 25% / 75%).
|
||||
weight: float = Field(default=1.0, gt=0)
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
strategy_key: str
|
||||
symbol: str | None = None
|
||||
@@ -142,6 +154,44 @@ class StartRequest(BaseModel):
|
||||
starting_balance: float | None = None
|
||||
|
||||
|
||||
class StartManyRequest(BaseModel):
|
||||
strategies: list[StrategySpec] = Field(min_length=1)
|
||||
starting_balance: float | None = None
|
||||
|
||||
|
||||
class StrategyStatusOut(BaseModel):
|
||||
"""Per-strategy status inside a multi-strategy run."""
|
||||
|
||||
strategy_key: str
|
||||
strategy_name: str | None
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
symbol: str
|
||||
timeframe: str
|
||||
allocation: float
|
||||
cash: float
|
||||
equity: float
|
||||
realized_pnl: float
|
||||
unrealized_pnl: float
|
||||
total_pnl: float
|
||||
open_positions: int
|
||||
halted: bool = False
|
||||
halt_reason: str | None = None
|
||||
trades_today: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class MultiStatusOut(BaseModel):
|
||||
running: bool
|
||||
mode: str
|
||||
exchange: str
|
||||
price: float | None = None
|
||||
starting_balance: float
|
||||
equity: float
|
||||
total_pnl: float
|
||||
strategies: list[StrategyStatusOut] = Field(default_factory=list)
|
||||
last_tick: datetime | None = None
|
||||
|
||||
|
||||
class ManualOrderRequest(BaseModel):
|
||||
side: Literal["buy", "sell"]
|
||||
qty: float | None = None
|
||||
|
||||
Reference in new issue
Block a user