Files
matitos 03dfa369e5
CI/CD / backend (pull_request) Successful in 6m18s
CI/CD / frontend (pull_request) Successful in 4m55s
CI/CD / build (pull_request) Skipped
CI/CD / deploy (pull_request) Skipped
CI/CD / cleanup (pull_request) Skipped
Risk controls, alerts, API token, more exchanges, presets
- 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).
2026-09-18 19:26:29 +00:00

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