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).
This commit is contained in:
1 parent
9c3bee87ac
commit
03dfa369e5
35 files changed
+1409
-45
No files matched your search
@@ -76,6 +76,73 @@ def test_backtest_limit_validation(runtime: Runtime) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_kill_endpoint(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
client.post("/api/bot/start", json={"strategy_key": "sma_cross"})
|
||||
killed = client.post("/api/bot/kill", json={"reason": "test kill"}).json()
|
||||
assert killed["running"] is False
|
||||
assert killed["halted"] is True
|
||||
assert killed["halt_reason"] == "test kill"
|
||||
|
||||
|
||||
def test_status_exposes_risk_fields(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
status = client.get("/api/status").json()
|
||||
assert {"halted", "halt_reason", "trades_today", "day_pnl", "drawdown_pct"} <= set(status)
|
||||
|
||||
|
||||
def test_preset_crud(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/presets",
|
||||
json={
|
||||
"name": "My SMA",
|
||||
"strategy_key": "sma_cross",
|
||||
"symbol": "BTC/USDT",
|
||||
"timeframe": "1h",
|
||||
"params": {"fast": 5, "slow": 20},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
preset = created.json()
|
||||
assert preset["name"] == "My SMA"
|
||||
|
||||
listed = client.get("/api/presets").json()
|
||||
assert any(p["id"] == preset["id"] for p in listed)
|
||||
|
||||
assert client.delete(f"/api/presets/{preset['id']}").status_code == 200
|
||||
assert client.get("/api/presets").json() == []
|
||||
|
||||
|
||||
def test_preset_unknown_strategy_404(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/presets",
|
||||
json={
|
||||
"strategy_key": "nope",
|
||||
"symbol": "BTC/USDT",
|
||||
"timeframe": "1m",
|
||||
"params": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_token_enforced(runtime: Runtime) -> None:
|
||||
runtime.settings.api_token = "secret"
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/status").status_code == 401
|
||||
ok = client.get("/api/status", headers={"X-API-Token": "secret"})
|
||||
assert ok.status_code == 200
|
||||
# Health stays open.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
def test_websocket_sends_initial_status(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -94,6 +95,69 @@ async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> N
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.market.coinbase import CoinbaseMarketData, to_product_id
|
||||
from app.market.kraken import KrakenMarketData
|
||||
|
||||
|
||||
def test_symbol_mapping() -> None:
|
||||
assert to_product_id("BTC/USDT") == "BTC-USDT"
|
||||
assert to_product_id("eth/usd") == "ETH-USD"
|
||||
|
||||
|
||||
def test_kraken_interval_mapping() -> None:
|
||||
assert KrakenMarketData._interval("1h") == 60
|
||||
assert KrakenMarketData._interval("1d") == 1440
|
||||
with pytest.raises(ValueError):
|
||||
KrakenMarketData._interval("2m")
|
||||
|
||||
|
||||
def test_coinbase_granularity_mapping() -> None:
|
||||
assert CoinbaseMarketData._granularity("1m") == 60
|
||||
assert CoinbaseMarketData._granularity("1d") == 86400
|
||||
with pytest.raises(ValueError):
|
||||
CoinbaseMarketData._granularity("3m")
|
||||
|
||||
|
||||
def test_build_market_data_supports_exchanges() -> None:
|
||||
from app.market import build_market_data
|
||||
|
||||
assert type(build_market_data(Settings(exchange="binance"))).__name__ == "BinanceMarketData"
|
||||
assert type(build_market_data(Settings(exchange="kraken"))).__name__ == "KrakenMarketData"
|
||||
assert type(build_market_data(Settings(exchange="coinbase"))).__name__ == "CoinbaseMarketData"
|
||||
|
||||
|
||||
def test_build_market_data_rejects_unknown() -> None:
|
||||
from app.market import build_market_data
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported exchange"):
|
||||
build_market_data(Settings(exchange="ftx"))
|
||||
|
||||
|
||||
def test_build_broker_paper_default() -> None:
|
||||
from app.broker import build_broker
|
||||
from app.broker.paper import PaperBroker
|
||||
|
||||
assert isinstance(build_broker(Settings(mode="paper", exchange="kraken")), PaperBroker)
|
||||
|
||||
|
||||
def test_build_broker_live_unsupported_exchange() -> None:
|
||||
from app.broker import build_broker
|
||||
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
build_broker(Settings(mode="live", exchange="kraken"))
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from app.db import _apply_additive_columns
|
||||
|
||||
|
||||
def test_additive_columns_added_to_legacy_table() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE strategy_configs ("
|
||||
"id INTEGER PRIMARY KEY, strategy_key VARCHAR(64), "
|
||||
"symbol VARCHAR(32), timeframe VARCHAR(16), params JSON, "
|
||||
"created_at DATETIME)"
|
||||
)
|
||||
)
|
||||
|
||||
_apply_additive_columns(engine)
|
||||
|
||||
columns = {col["name"] for col in inspect(engine).get_columns("strategy_configs")}
|
||||
assert "name" in columns
|
||||
|
||||
|
||||
def test_additive_columns_is_idempotent() -> None:
|
||||
engine = create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE strategy_configs ("
|
||||
"id INTEGER PRIMARY KEY, name VARCHAR(80), strategy_key VARCHAR(64), "
|
||||
"symbol VARCHAR(32), timeframe VARCHAR(16), params JSON, "
|
||||
"created_at DATETIME)"
|
||||
)
|
||||
)
|
||||
|
||||
_apply_additive_columns(engine)
|
||||
_apply_additive_columns(engine)
|
||||
|
||||
columns = [col["name"] for col in inspect(engine).get_columns("strategy_configs")]
|
||||
assert columns.count("name") == 1
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.config import Settings
|
||||
from app.notify import FanoutNotifier, Notifier, WebhookNotifier, build_notifier
|
||||
from app.runtime import Runtime
|
||||
|
||||
|
||||
class RecordingNotifier(Notifier):
|
||||
def __init__(self, name: str = "rec", fail: bool = False) -> None:
|
||||
self.name = name
|
||||
self.fail = fail
|
||||
self.messages: list[tuple[str, str]] = []
|
||||
|
||||
async def send(self, level: str, message: str) -> None:
|
||||
if self.fail:
|
||||
raise RuntimeError("boom")
|
||||
self.messages.append((level, message))
|
||||
|
||||
|
||||
async def test_fanout_sends_to_all() -> None:
|
||||
first = RecordingNotifier("a")
|
||||
second = RecordingNotifier("b")
|
||||
await FanoutNotifier([first, second]).send("trade", "hello")
|
||||
assert first.messages == [("trade", "hello")]
|
||||
assert second.messages == [("trade", "hello")]
|
||||
|
||||
|
||||
async def test_fanout_survives_a_failing_channel() -> None:
|
||||
bad = RecordingNotifier("bad", fail=True)
|
||||
good = RecordingNotifier("good")
|
||||
await FanoutNotifier([bad, good]).send("error", "still delivered")
|
||||
assert good.messages == [("error", "still delivered")]
|
||||
|
||||
|
||||
def test_build_notifier_none_without_config() -> None:
|
||||
assert build_notifier(Settings(telegram_bot_token="", notify_webhook_url="")) is None
|
||||
|
||||
|
||||
def test_build_notifier_telegram_requires_both_fields() -> None:
|
||||
assert build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="")) is None
|
||||
notifier = build_notifier(Settings(telegram_bot_token="t", telegram_chat_id="c"))
|
||||
assert notifier is not None
|
||||
|
||||
|
||||
def test_build_notifier_webhook() -> None:
|
||||
notifier = build_notifier(Settings(notify_webhook_url="https://example.com/hook"))
|
||||
assert isinstance(notifier, FanoutNotifier)
|
||||
assert any(isinstance(n, WebhookNotifier) for n in notifier.notifiers)
|
||||
|
||||
|
||||
async def test_runtime_notifies_on_trade_events() -> None:
|
||||
recorder = RecordingNotifier()
|
||||
settings = Settings(telegram_bot_token="", notify_webhook_url="")
|
||||
runtime = Runtime(settings, notifier=recorder)
|
||||
runtime._broadcast({"type": "event", "data": {"level": "trade", "message": "opened long"}})
|
||||
await asyncio.gather(*list(runtime._notify_tasks))
|
||||
assert recorder.messages == [("trade", "opened long")]
|
||||
|
||||
|
||||
async def test_runtime_ignores_info_events() -> None:
|
||||
recorder = RecordingNotifier()
|
||||
settings = Settings(telegram_bot_token="", notify_webhook_url="")
|
||||
runtime = Runtime(settings, notifier=recorder)
|
||||
runtime._broadcast({"type": "event", "data": {"level": "info", "message": "started"}})
|
||||
await asyncio.gather(*list(runtime._notify_tasks))
|
||||
assert recorder.messages == []
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.risk import RiskManager
|
||||
|
||||
|
||||
def test_daily_loss_detects_and_halts() -> None:
|
||||
risk = RiskManager(max_daily_loss=100.0)
|
||||
risk.reset(10_000)
|
||||
assert risk.update(9_950) is None
|
||||
reason = risk.update(9_900)
|
||||
assert reason is not None and "daily loss" in reason
|
||||
assert risk.halted is False # detection only; caller decides
|
||||
risk.halt(reason)
|
||||
assert risk.halted is True
|
||||
assert risk.can_enter() is False
|
||||
|
||||
|
||||
def test_drawdown_halts() -> None:
|
||||
risk = RiskManager(max_drawdown_pct=10.0)
|
||||
risk.reset(10_000)
|
||||
risk.update(11_000)
|
||||
assert risk.update(10_000) is None
|
||||
reason = risk.update(9_850)
|
||||
assert reason is not None and "drawdown" in reason
|
||||
risk.halt(reason)
|
||||
assert risk.halted
|
||||
|
||||
|
||||
def test_halt_is_sticky() -> None:
|
||||
risk = RiskManager(max_daily_loss=100.0)
|
||||
risk.reset(10_000)
|
||||
reason = risk.update(9_800)
|
||||
risk.halt(reason or "loss")
|
||||
assert risk.halted
|
||||
# A recovery must not silently resume trading.
|
||||
assert risk.update(10_500) is not None
|
||||
assert risk.halted
|
||||
|
||||
|
||||
def test_max_trades_per_day() -> None:
|
||||
risk = RiskManager(max_trades_per_day=2)
|
||||
risk.reset(10_000)
|
||||
assert risk.can_enter()
|
||||
risk.record_entry()
|
||||
risk.record_entry()
|
||||
assert risk.can_enter() is False
|
||||
assert "max trades" in (risk.entry_block_reason() or "")
|
||||
|
||||
|
||||
def test_clamp_size() -> None:
|
||||
risk = RiskManager(max_position_pct=0.25)
|
||||
assert risk.clamp_size(1.0) == pytest.approx(0.25)
|
||||
assert risk.clamp_size(0.1) == pytest.approx(0.1)
|
||||
assert risk.clamp_size(-1.0) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_disabled_limits_never_halt() -> None:
|
||||
risk = RiskManager()
|
||||
risk.reset(10_000)
|
||||
assert risk.update(1.0) is None
|
||||
assert risk.can_enter()
|
||||
Reference in new issue
Block a user