- RiskManager halts + flattens on max daily loss / drawdown, caps position size and trades per day; adds a manual kill switch (POST /api/bot/kill). - Telegram and webhook notifications on trades, errors and halts. - Optional API bearer token on /api, injected server-side by the frontend nginx so the browser never sees it. - Kraken and Coinbase market data (public); Coinbase live broker. Live on an unsupported exchange now errors instead of silently falling back to paper. - Strategy presets backed by the StrategyConfig table (CRUD API + UI). - Idempotent additive-column migration so existing SQLite databases upgrade in place (fixes the new strategy_configs.name column).
175 lines
5.9 KiB
Python
175 lines
5.9 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import ClassVar
|
|
|
|
import pytest
|
|
|
|
from app.broker.paper import PaperBroker
|
|
from app.config import Settings
|
|
from app.engine.engine import TradingEngine
|
|
from app.engine.risk import RiskManager # noqa: F401 (documents the dependency)
|
|
from app.engine.strategies.base import Strategy, StrategyContext
|
|
from app.market.fake import FakeMarketData
|
|
from app.models import OrderSide, PositionSide
|
|
from app.schemas import Candle
|
|
from app.store import Store
|
|
|
|
|
|
class ToggleStrategy(Strategy):
|
|
key: ClassVar[str] = "test_toggle"
|
|
name: ClassVar[str] = "Toggle"
|
|
default_params: ClassVar[dict] = {}
|
|
|
|
def on_candle(self, ctx: StrategyContext) -> None:
|
|
if ctx.is_flat:
|
|
ctx.enter_long(size=0.5)
|
|
else:
|
|
ctx.close()
|
|
|
|
|
|
def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine:
|
|
return TradingEngine(
|
|
feed=market,
|
|
broker=PaperBroker(fee_bps=0, slippage_bps=0),
|
|
store=store,
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None:
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
|
|
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
|
|
assert engine.position is not None
|
|
assert engine.cash == pytest.approx(9_900.0)
|
|
assert engine.equity == pytest.approx(10_000.0)
|
|
|
|
engine.last_price = 110.0
|
|
assert engine.equity == pytest.approx(10_010.0)
|
|
assert engine.unrealized_pnl == pytest.approx(10.0)
|
|
|
|
await engine.close_current_position()
|
|
assert engine.position is None
|
|
assert engine.realized_pnl == pytest.approx(10.0)
|
|
assert engine.cash == pytest.approx(10_010.0)
|
|
|
|
|
|
async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None:
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
|
|
await engine._open_position(
|
|
PositionSide.LONG,
|
|
size=0.0,
|
|
stop_loss=95.0,
|
|
take_profit=120.0,
|
|
reason="entry",
|
|
qty_override=1.0,
|
|
)
|
|
await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1))
|
|
assert engine.position is None
|
|
assert engine.realized_pnl == pytest.approx(-5.0)
|
|
|
|
|
|
async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None:
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
|
|
await engine._open_position(
|
|
PositionSide.LONG,
|
|
size=0.0,
|
|
stop_loss=95.0,
|
|
take_profit=110.0,
|
|
reason="entry",
|
|
qty_override=1.0,
|
|
)
|
|
await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1))
|
|
assert engine.position is None
|
|
assert engine.realized_pnl == pytest.approx(10.0)
|
|
|
|
|
|
async def test_daily_loss_halts_and_flattens(settings: Settings, store: Store) -> None:
|
|
settings.max_daily_loss = 50.0
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
engine.risk.reset(10_000)
|
|
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
|
|
|
|
market.candles[0] = Candle(ts=0, open=100, high=100, low=40, close=40, volume=1)
|
|
await engine.tick()
|
|
|
|
assert engine.risk.halted is True
|
|
assert engine.position is None
|
|
assert engine.running is False
|
|
|
|
|
|
async def test_kill_switch_flattens_and_stops(settings: Settings, store: Store) -> None:
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
|
|
assert engine.position is not None
|
|
|
|
await engine.kill("panic")
|
|
|
|
assert engine.running is False
|
|
assert engine.risk.halted is True
|
|
assert engine.position is None
|
|
assert engine.status().halt_reason == "panic"
|
|
|
|
|
|
async def test_max_trades_per_day_blocks_new_entries(settings: Settings, store: Store) -> None:
|
|
settings.max_trades_per_day = 1
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
engine.risk.reset(10_000)
|
|
|
|
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="first")
|
|
assert engine.position is not None
|
|
await engine.close_current_position()
|
|
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="second")
|
|
assert engine.position is None
|
|
|
|
|
|
async def test_max_position_pct_clamps_size(settings: Settings, store: Store) -> None:
|
|
settings.max_position_pct = 0.5
|
|
market = FakeMarketData.from_closes([100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.symbol = "BTC/USDT"
|
|
engine.last_price = 100.0
|
|
engine.risk.reset(10_000)
|
|
|
|
await engine._open_position(
|
|
PositionSide.LONG, size=1.0, stop_loss=None, take_profit=None, reason="big"
|
|
)
|
|
assert engine.position is not None
|
|
assert engine.position.qty == pytest.approx(50.0)
|
|
|
|
|
|
async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None:
|
|
market = FakeMarketData.from_closes([100.0, 100.0])
|
|
engine = build_engine(settings, store, market)
|
|
engine.strategy = ToggleStrategy()
|
|
engine.strategy_key = "test_toggle"
|
|
engine.symbol = "BTC/USDT"
|
|
|
|
await engine.tick()
|
|
assert engine.position is not None
|
|
assert engine.position.side == PositionSide.LONG
|
|
|
|
market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1))
|
|
await engine.tick()
|
|
assert engine.position is None
|