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
+81
-12
@@ -6,7 +6,8 @@ from typing import Annotated
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.engine.strategies import list_strategies, strategy_spec
|
||||
from app.config import Settings
|
||||
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
|
||||
from app.models import OrderSide, Position, PositionStatus
|
||||
from app.runtime import Runtime
|
||||
from app.schemas import (
|
||||
@@ -15,11 +16,14 @@ from app.schemas import (
|
||||
ClosePositionRequest,
|
||||
EquityPointOut,
|
||||
EventOut,
|
||||
KillRequest,
|
||||
ManualOrderRequest,
|
||||
OrderOut,
|
||||
PositionOut,
|
||||
StartRequest,
|
||||
StatusOut,
|
||||
StrategyConfigIn,
|
||||
StrategyConfigOut,
|
||||
StrategyOut,
|
||||
)
|
||||
|
||||
@@ -30,7 +34,26 @@ def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
RuntimeDep = Annotated[Runtime, Depends(get_runtime)]
|
||||
def get_settings(request: Request) -> Settings:
|
||||
return request.app.state.runtime.settings
|
||||
|
||||
|
||||
def get_guarded_runtime(request: Request) -> Runtime:
|
||||
"""Return the runtime, rejecting requests without the configured API token.
|
||||
|
||||
The check is a no-op when ``API_TOKEN`` is unset, so local dev and the
|
||||
Basic-auth-only deployment keep working unchanged.
|
||||
"""
|
||||
runtime: Runtime = request.app.state.runtime
|
||||
settings: Settings = runtime.settings
|
||||
if settings.api_token:
|
||||
provided = request.headers.get(settings.api_token_header, "")
|
||||
if provided != settings.api_token:
|
||||
raise HTTPException(status_code=401, detail="invalid or missing API token")
|
||||
return runtime
|
||||
|
||||
|
||||
GuardedRuntimeDep = Annotated[Runtime, Depends(get_guarded_runtime)]
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -44,12 +67,12 @@ def strategies() -> list[StrategyOut]:
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusOut)
|
||||
def status(runtime: RuntimeDep) -> StatusOut:
|
||||
def status(runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return runtime.status()
|
||||
|
||||
|
||||
@router.post("/bot/start", response_model=StatusOut)
|
||||
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
async def start_bot(payload: StartRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
try:
|
||||
return await runtime.start(
|
||||
payload.strategy_key,
|
||||
@@ -65,12 +88,17 @@ async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
|
||||
|
||||
@router.post("/bot/stop", response_model=StatusOut)
|
||||
async def stop_bot(runtime: RuntimeDep) -> StatusOut:
|
||||
async def stop_bot(runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return await runtime.stop()
|
||||
|
||||
|
||||
@router.post("/bot/kill", response_model=StatusOut)
|
||||
async def kill_bot(payload: KillRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
return await runtime.kill(payload.reason)
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResult)
|
||||
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult:
|
||||
async def run_backtest(payload: BacktestRequest, runtime: GuardedRuntimeDep) -> BacktestResult:
|
||||
try:
|
||||
return await runtime.run_backtest(payload)
|
||||
except KeyError as exc:
|
||||
@@ -81,6 +109,36 @@ async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> Backtes
|
||||
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
|
||||
|
||||
|
||||
@router.get("/presets", response_model=list[StrategyConfigOut])
|
||||
def list_presets(runtime: GuardedRuntimeDep) -> list[StrategyConfigOut]:
|
||||
return [
|
||||
StrategyConfigOut.model_validate(config) for config in runtime.store.list_strategy_configs()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/presets", response_model=StrategyConfigOut)
|
||||
def create_preset(payload: StrategyConfigIn, runtime: GuardedRuntimeDep) -> StrategyConfigOut:
|
||||
try:
|
||||
get_strategy(payload.strategy_key)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
config = runtime.store.save_strategy_config(
|
||||
name=payload.name,
|
||||
strategy_key=payload.strategy_key,
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
params=payload.params,
|
||||
)
|
||||
return StrategyConfigOut.model_validate(config)
|
||||
|
||||
|
||||
@router.delete("/presets/{config_id}")
|
||||
def delete_preset(config_id: int, runtime: GuardedRuntimeDep) -> dict[str, bool]:
|
||||
if not runtime.store.delete_strategy_config(config_id):
|
||||
raise HTTPException(status_code=404, detail="preset not found")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
def _position_out(position: Position, price: float | None) -> PositionOut:
|
||||
out = PositionOut.model_validate(position)
|
||||
if position.status == PositionStatus.OPEN and price is not None:
|
||||
@@ -90,28 +148,28 @@ def _position_out(position: Position, price: float | None) -> PositionOut:
|
||||
|
||||
|
||||
@router.get("/positions", response_model=list[PositionOut])
|
||||
def positions(runtime: RuntimeDep, limit: int = 50) -> list[PositionOut]:
|
||||
def positions(runtime: GuardedRuntimeDep, limit: int = 50) -> list[PositionOut]:
|
||||
price = runtime.engine.last_price if runtime.engine else None
|
||||
return [_position_out(position, price) for position in runtime.store.list_positions(limit)]
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[OrderOut])
|
||||
def orders(runtime: RuntimeDep, limit: int = 50) -> list[OrderOut]:
|
||||
def orders(runtime: GuardedRuntimeDep, limit: int = 50) -> list[OrderOut]:
|
||||
return [OrderOut.model_validate(order) for order in runtime.store.list_orders(limit)]
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[EventOut])
|
||||
def events(runtime: RuntimeDep, limit: int = 50) -> list[EventOut]:
|
||||
def events(runtime: GuardedRuntimeDep, limit: int = 50) -> list[EventOut]:
|
||||
return [EventOut.model_validate(event) for event in runtime.store.list_events(limit)]
|
||||
|
||||
|
||||
@router.get("/equity", response_model=list[EquityPointOut])
|
||||
def equity(runtime: RuntimeDep, limit: int = 500) -> list[EquityPointOut]:
|
||||
def equity(runtime: GuardedRuntimeDep, limit: int = 500) -> list[EquityPointOut]:
|
||||
return [EquityPointOut.model_validate(point) for point in runtime.store.list_equity(limit)]
|
||||
|
||||
|
||||
@router.post("/orders", response_model=StatusOut)
|
||||
async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
async def manual_order(payload: ManualOrderRequest, runtime: GuardedRuntimeDep) -> StatusOut:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
raise HTTPException(status_code=409, detail="engine is not running")
|
||||
@@ -122,7 +180,7 @@ async def manual_order(payload: ManualOrderRequest, runtime: RuntimeDep) -> Stat
|
||||
|
||||
@router.post("/positions/{position_id}/close", response_model=StatusOut)
|
||||
async def close_position(
|
||||
position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep
|
||||
position_id: int, payload: ClosePositionRequest, runtime: GuardedRuntimeDep
|
||||
) -> StatusOut:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
@@ -136,6 +194,17 @@ async def close_position(
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
runtime: Runtime = websocket.app.state.runtime
|
||||
settings: Settings = runtime.settings
|
||||
if settings.api_token:
|
||||
# The frontend nginx injects the token as a header on the upgrade request
|
||||
# (browsers can't set WebSocket headers themselves); accept a query param
|
||||
# too for direct/manual clients.
|
||||
provided = websocket.headers.get(settings.api_token_header) or websocket.query_params.get(
|
||||
"token", ""
|
||||
)
|
||||
if provided != settings.api_token:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
await websocket.accept()
|
||||
queue = runtime.subscribe()
|
||||
try:
|
||||
|
||||
@@ -2,19 +2,41 @@ from __future__ import annotations
|
||||
|
||||
from app.broker.base import Broker, Fill
|
||||
from app.broker.binance import TESTNET_URL, BinanceBroker
|
||||
from app.broker.coinbase import CoinbaseBroker
|
||||
from app.broker.paper import PaperBroker
|
||||
from app.config import Settings
|
||||
from app.market.binance import BASE_URL
|
||||
|
||||
# Exchanges with a live execution adapter. Others fall back to paper and the
|
||||
# engine surfaces a clear error at start.
|
||||
_LIVE_BROKERS: dict[str, type[Broker]] = {
|
||||
"binance": BinanceBroker,
|
||||
"binanceus": BinanceBroker,
|
||||
"coinbase": CoinbaseBroker,
|
||||
}
|
||||
|
||||
|
||||
def build_broker(settings: Settings) -> Broker:
|
||||
if settings.mode == "live":
|
||||
if settings.mode != "live":
|
||||
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps)
|
||||
|
||||
exchange = settings.exchange.lower()
|
||||
if exchange not in _LIVE_BROKERS:
|
||||
raise ValueError(
|
||||
f"live trading is not supported on '{settings.exchange}' yet; "
|
||||
f"use MODE=paper or one of {sorted(_LIVE_BROKERS)}"
|
||||
)
|
||||
|
||||
if exchange in {"binance", "binanceus"}:
|
||||
return BinanceBroker(
|
||||
api_key=settings.exchange_api_key,
|
||||
api_secret=settings.exchange_api_secret,
|
||||
base_url=TESTNET_URL if settings.exchange_sandbox else BASE_URL,
|
||||
)
|
||||
return PaperBroker(fee_bps=settings.fee_bps, slippage_bps=settings.slippage_bps)
|
||||
return CoinbaseBroker(
|
||||
api_key=settings.exchange_api_key,
|
||||
api_secret=settings.exchange_api_secret,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"]
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from app.broker.base import Broker, Fill
|
||||
from app.market.coinbase import BASE_URL, to_product_id
|
||||
from app.models import OrderSide, utcnow
|
||||
|
||||
|
||||
class CoinbaseBroker(Broker):
|
||||
"""Live Coinbase Advanced Trade execution (requires API credentials).
|
||||
|
||||
Credentials are a Coinbase CDP API key + an EC private key (PEM). The key
|
||||
name is ``EXCHANGE_API_KEY`` and the PEM is ``EXCHANGE_API_SECRET`` (with
|
||||
literal ``\\n`` escapes allowed).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
base_url: str = BASE_URL,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
if not api_key or not api_secret:
|
||||
raise ValueError(
|
||||
"Coinbase broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET (PEM)"
|
||||
)
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret
|
||||
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
||||
|
||||
def _token(self, method: str, path: str) -> str:
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
nonce = str(uuid.uuid4())
|
||||
timestamp = str(int(time.time()))
|
||||
message = f"{timestamp}{method}{path}{nonce}"
|
||||
private_key = serialization.load_pem_private_key(
|
||||
self.api_secret.replace("\\n", "\n").encode(), password=None
|
||||
)
|
||||
signature = private_key.sign(message.encode(), ec.ECDSA(hashes.SHA256()))
|
||||
return f"Bearer {self.api_key}:{base64.b64encode(signature).decode()}:{nonce}:{timestamp}"
|
||||
|
||||
async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill:
|
||||
path = "/api/v3/brokerage/orders"
|
||||
body = {
|
||||
"client_order_id": str(uuid.uuid4()),
|
||||
"product_id": to_product_id(symbol),
|
||||
"side": side.value.upper(),
|
||||
"order_configuration": {"market_market_ioc": {"base_size": f"{qty:.8f}"}},
|
||||
}
|
||||
response = await self._client.post(
|
||||
path,
|
||||
content=json.dumps(body),
|
||||
headers={
|
||||
"Authorization": self._token("POST", path),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
order = data.get("order", data)
|
||||
success = order.get("success", True)
|
||||
if not success:
|
||||
raise RuntimeError(f"coinbase order rejected: {order.get('failure_reason', order)}")
|
||||
filled = float(order.get("filled_size") or qty)
|
||||
price = float(order.get("average_filled_price") or 0.0)
|
||||
fee = float(order.get("total_fees") or 0.0)
|
||||
return Fill(symbol=symbol, side=side, qty=filled, price=price, fee=fee, ts=utcnow())
|
||||
|
||||
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
return await self._market_order(symbol, OrderSide.BUY, qty)
|
||||
|
||||
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
return await self._market_order(symbol, OrderSide.SELL, qty)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -39,6 +39,26 @@ class Settings(BaseSettings):
|
||||
exchange_api_secret: str = ""
|
||||
exchange_sandbox: bool = False
|
||||
|
||||
# --- Risk controls (0 disables a limit) ---
|
||||
# Absolute loss in quote currency since the start of the UTC day.
|
||||
max_daily_loss: float = 0.0
|
||||
# Percentage fall from peak equity.
|
||||
max_drawdown_pct: float = 0.0
|
||||
# Cap on a single position as a fraction of equity (1.0 = all-in).
|
||||
max_position_pct: float = 1.0
|
||||
# Maximum number of new entries per UTC day.
|
||||
max_trades_per_day: int = 0
|
||||
|
||||
# --- Notifications ---
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
notify_webhook_url: str = ""
|
||||
|
||||
# Optional bearer token required on /api requests (defense-in-depth behind
|
||||
# the Traefik Basic auth). Empty disables the check.
|
||||
api_token: str = ""
|
||||
api_token_header: str = "X-API-Token"
|
||||
|
||||
cors_origins: str = "*"
|
||||
|
||||
@field_validator("mode")
|
||||
|
||||
+26
-1
@@ -1,15 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import Engine, create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import REPO_ROOT, get_settings
|
||||
from app.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# Additive migrations for existing databases. ``create_all`` only creates missing
|
||||
# tables; it never alters existing ones, so new nullable/defaulted columns are
|
||||
# added here as (table, column, DDL type) tuples.
|
||||
_ADDITIVE_COLUMNS: list[tuple[str, str, str]] = [
|
||||
("strategy_configs", "name", "VARCHAR(80) DEFAULT ''"),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> tuple[str, dict]:
|
||||
connect_args: dict = {}
|
||||
@@ -26,8 +36,23 @@ engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def _apply_additive_columns(target: Engine) -> None:
|
||||
inspector = inspect(target)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
with target.begin() as connection:
|
||||
for table, column, ddl in _ADDITIVE_COLUMNS:
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
columns = {col["name"] for col in inspector.get_columns(table)}
|
||||
if column in columns:
|
||||
continue
|
||||
logger.info("migrating: adding %s.%s", table, column)
|
||||
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}"))
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_apply_additive_columns(engine)
|
||||
|
||||
|
||||
def get_session() -> Iterator[Session]:
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.engine.portfolio import (
|
||||
risk_exit,
|
||||
unrealized_pnl,
|
||||
)
|
||||
from app.engine.risk import RiskManager
|
||||
from app.engine.strategies import Strategy, get_strategy
|
||||
from app.engine.strategies.base import StrategyContext
|
||||
from app.market.base import MarketData
|
||||
@@ -61,6 +62,8 @@ class TradingEngine:
|
||||
self.symbol: str = settings.symbol
|
||||
self.timeframe: str = settings.timeframe
|
||||
|
||||
self.risk = RiskManager.from_settings(settings, settings.starting_balance)
|
||||
self._entry_blocked_logged = False
|
||||
self._task: asyncio.Task | None = None
|
||||
self._publish_cb: Publish | None = None
|
||||
|
||||
@@ -94,6 +97,8 @@ class TradingEngine:
|
||||
self.last_price = None
|
||||
self.error = None
|
||||
self.last_tick = None
|
||||
self.risk.reset(self.starting_balance)
|
||||
self._entry_blocked_logged = False
|
||||
|
||||
self.running = True
|
||||
self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}")
|
||||
@@ -127,6 +132,8 @@ class TradingEngine:
|
||||
except Exception as exc: # keep the loop alive on transient errors
|
||||
self.error = f"{type(exc).__name__}: {exc}"
|
||||
self._emit("error", f"tick failed: {self.error}")
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(self.settings.poll_interval)
|
||||
|
||||
async def tick(self) -> None:
|
||||
@@ -139,6 +146,11 @@ class TradingEngine:
|
||||
self.last_tick = datetime.now(UTC)
|
||||
self.error = None
|
||||
|
||||
halt_reason = self.risk.update(self.equity)
|
||||
if halt_reason is not None:
|
||||
await self._halt(halt_reason)
|
||||
return
|
||||
|
||||
if self.position is not None:
|
||||
await self._check_risk(candles[-1])
|
||||
|
||||
@@ -207,10 +219,17 @@ class TradingEngine:
|
||||
if not price or price <= 0:
|
||||
return
|
||||
|
||||
if not self.risk.can_enter():
|
||||
if not self._entry_blocked_logged:
|
||||
self._entry_blocked_logged = True
|
||||
reason = self.risk.entry_block_reason() or "entry blocked"
|
||||
self._emit("warning", f"Entry blocked: {reason}")
|
||||
return
|
||||
|
||||
if qty_override is not None:
|
||||
qty = qty_override
|
||||
else:
|
||||
qty = order_qty(self.equity, size, side, self.cash, price)
|
||||
qty = order_qty(self.equity, self.risk.clamp_size(size), side, self.cash, price)
|
||||
if qty <= 0:
|
||||
return
|
||||
|
||||
@@ -242,6 +261,7 @@ class TradingEngine:
|
||||
fee=fill.fee,
|
||||
reason=reason,
|
||||
)
|
||||
self.risk.record_entry()
|
||||
self._emit(
|
||||
"trade",
|
||||
f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})",
|
||||
@@ -309,6 +329,30 @@ class TradingEngine:
|
||||
self.last_price = level
|
||||
await self._close_position(reason)
|
||||
|
||||
# --- halt / kill switch -------------------------------------------
|
||||
async def _halt(self, reason: str) -> None:
|
||||
"""Flatten and stop trading. Sticky until the next start()."""
|
||||
if self.risk.halted:
|
||||
return
|
||||
self.risk.halt(reason)
|
||||
self._emit("error", f"HALTED: {reason}")
|
||||
if self.position is not None:
|
||||
if self.last_price is None:
|
||||
self.last_price = await self.feed.fetch_price(self.symbol)
|
||||
await self._close_position(f"halt: {reason}")
|
||||
self.running = False
|
||||
self._broadcast_status()
|
||||
|
||||
async def kill(self, reason: str = "manual kill switch") -> None:
|
||||
"""Manual kill switch: halt, flatten, and cancel the loop task."""
|
||||
await self._halt(reason)
|
||||
if self._task is not None and not self._task.done():
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
self._broadcast_status()
|
||||
|
||||
# --- manual controls ----------------------------------------------
|
||||
async def manual_order(
|
||||
self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str
|
||||
@@ -374,6 +418,11 @@ class TradingEngine:
|
||||
open_positions=1 if self.position else 0,
|
||||
last_tick=self.last_tick,
|
||||
error=self.error,
|
||||
halted=self.risk.halted,
|
||||
halt_reason=self.risk.halt_reason,
|
||||
trades_today=self.risk.trades_today,
|
||||
day_pnl=self.risk.day_pnl,
|
||||
drawdown_pct=self.risk.drawdown_pct,
|
||||
)
|
||||
|
||||
def _emit(self, level: str, message: str) -> None:
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class RiskManager:
|
||||
"""Tracks per-session and per-day limits and can halt trading.
|
||||
|
||||
A halt is sticky until ``reset`` is called (which happens on engine start).
|
||||
All limits are optional; a value of ``0`` disables that limit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_daily_loss: float = 0.0,
|
||||
max_drawdown_pct: float = 0.0,
|
||||
max_position_pct: float = 1.0,
|
||||
max_trades_per_day: int = 0,
|
||||
) -> None:
|
||||
self.max_daily_loss = max_daily_loss
|
||||
self.max_drawdown_pct = max_drawdown_pct
|
||||
self.max_position_pct = max_position_pct
|
||||
self.max_trades_per_day = max_trades_per_day
|
||||
self.reset(0.0)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Settings, starting_equity: float) -> RiskManager:
|
||||
manager = cls(
|
||||
max_daily_loss=settings.max_daily_loss,
|
||||
max_drawdown_pct=settings.max_drawdown_pct,
|
||||
max_position_pct=settings.max_position_pct,
|
||||
max_trades_per_day=settings.max_trades_per_day,
|
||||
)
|
||||
manager.reset(starting_equity)
|
||||
return manager
|
||||
|
||||
def reset(self, starting_equity: float) -> None:
|
||||
self.starting_equity = starting_equity
|
||||
self.peak_equity = starting_equity
|
||||
self.last_equity = starting_equity
|
||||
self.day_start_equity = starting_equity
|
||||
self.day = datetime.now(UTC).date()
|
||||
self.trades_today = 0
|
||||
self.halted = False
|
||||
self.halt_reason: str | None = None
|
||||
|
||||
@property
|
||||
def day_pnl(self) -> float:
|
||||
return self.last_equity - self.day_start_equity
|
||||
|
||||
@property
|
||||
def drawdown_pct(self) -> float:
|
||||
if self.peak_equity <= 0:
|
||||
return 0.0
|
||||
return max(0.0, (self.peak_equity - self.last_equity) / self.peak_equity * 100)
|
||||
|
||||
def _roll_day(self, equity: float) -> None:
|
||||
today = datetime.now(UTC).date()
|
||||
if today != self.day:
|
||||
self.day = today
|
||||
self.day_start_equity = equity
|
||||
self.trades_today = 0
|
||||
|
||||
def update(self, equity: float) -> str | None:
|
||||
"""Update with the latest equity; return a halt reason if a limit tripped.
|
||||
|
||||
Detection only — the caller decides when to actually halt (so it can
|
||||
flatten first). Use ``halt()`` to make it sticky.
|
||||
"""
|
||||
self.last_equity = equity
|
||||
self._roll_day(equity)
|
||||
self.peak_equity = max(self.peak_equity, equity)
|
||||
if self.halted:
|
||||
return self.halt_reason
|
||||
if self.max_daily_loss > 0 and self.day_pnl <= -self.max_daily_loss:
|
||||
return f"daily loss limit hit (P&L {self.day_pnl:.2f})"
|
||||
if self.max_drawdown_pct > 0 and self.drawdown_pct >= self.max_drawdown_pct:
|
||||
return f"max drawdown hit ({self.drawdown_pct:.2f}%)"
|
||||
return None
|
||||
|
||||
def halt(self, reason: str) -> str:
|
||||
self.halted = True
|
||||
self.halt_reason = reason
|
||||
return reason
|
||||
|
||||
def can_enter(self) -> bool:
|
||||
return self.entry_block_reason() is None
|
||||
|
||||
def entry_block_reason(self) -> str | None:
|
||||
if self.halted:
|
||||
return self.halt_reason
|
||||
if self.max_trades_per_day > 0 and self.trades_today >= self.max_trades_per_day:
|
||||
return f"max trades per day reached ({self.max_trades_per_day})"
|
||||
return None
|
||||
|
||||
def record_entry(self) -> None:
|
||||
self.trades_today += 1
|
||||
|
||||
def clamp_size(self, size: float) -> float:
|
||||
return max(0.0, min(size, self.max_position_pct))
|
||||
@@ -3,14 +3,30 @@ from __future__ import annotations
|
||||
from app.config import Settings
|
||||
from app.market.base import MarketData
|
||||
from app.market.binance import BinanceMarketData
|
||||
from app.market.coinbase import CoinbaseMarketData
|
||||
from app.market.fake import FakeMarketData
|
||||
from app.market.kraken import KrakenMarketData
|
||||
|
||||
_FEEDS: dict[str, type[MarketData]] = {
|
||||
"binance": BinanceMarketData,
|
||||
"binanceus": BinanceMarketData,
|
||||
"kraken": KrakenMarketData,
|
||||
"coinbase": CoinbaseMarketData,
|
||||
}
|
||||
|
||||
|
||||
def build_market_data(settings: Settings) -> MarketData:
|
||||
exchange = settings.exchange.lower()
|
||||
if exchange in {"binance", "binanceus"}:
|
||||
return BinanceMarketData()
|
||||
raise ValueError(f"unsupported exchange: {settings.exchange}")
|
||||
try:
|
||||
feed_cls = _FEEDS[settings.exchange.lower()]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unsupported exchange: {settings.exchange}. available: {sorted(_FEEDS)}"
|
||||
) from None
|
||||
return feed_cls()
|
||||
|
||||
|
||||
__all__ = ["FakeMarketData", "MarketData", "build_market_data"]
|
||||
__all__ = [
|
||||
"FakeMarketData",
|
||||
"MarketData",
|
||||
"build_market_data",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import Candle
|
||||
|
||||
BASE_URL = "https://api.exchange.coinbase.com"
|
||||
|
||||
# Coinbase granularities are in seconds.
|
||||
GRANULARITIES: dict[str, int] = {
|
||||
"1m": 60,
|
||||
"5m": 300,
|
||||
"15m": 900,
|
||||
"1h": 3600,
|
||||
"6h": 21600,
|
||||
"1d": 86400,
|
||||
}
|
||||
|
||||
|
||||
def to_product_id(symbol: str) -> str:
|
||||
return symbol.replace("/", "-").upper()
|
||||
|
||||
|
||||
class CoinbaseMarketData(MarketData):
|
||||
"""Public Coinbase Exchange market data over REST (no API key required)."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0) -> None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
headers={"User-Agent": "crypto_trading/0.1"},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _granularity(timeframe: str) -> int:
|
||||
try:
|
||||
return GRANULARITIES[timeframe]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unsupported timeframe '{timeframe}' for Coinbase; "
|
||||
f"use one of {sorted(GRANULARITIES)}"
|
||||
) from None
|
||||
|
||||
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
|
||||
response = await self._client.get(
|
||||
f"/products/{to_product_id(symbol)}/candles",
|
||||
params={"granularity": self._granularity(timeframe)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
# Coinbase returns newest-first rows: [time, low, high, open, close, volume].
|
||||
rows = sorted(response.json(), key=lambda row: row[0])
|
||||
candles = [
|
||||
Candle(
|
||||
ts=int(row[0]) * 1000,
|
||||
open=float(row[3]),
|
||||
high=float(row[2]),
|
||||
low=float(row[1]),
|
||||
close=float(row[4]),
|
||||
volume=float(row[5]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return candles[-limit:]
|
||||
|
||||
async def fetch_price(self, symbol: str) -> float:
|
||||
response = await self._client.get(f"/products/{to_product_id(symbol)}/ticker")
|
||||
response.raise_for_status()
|
||||
return float(response.json()["price"])
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import Candle
|
||||
|
||||
BASE_URL = "https://api.kraken.com"
|
||||
|
||||
# Kraken intervals are in minutes; map our timeframe strings to them.
|
||||
INTERVALS: dict[str, int] = {
|
||||
"1m": 1,
|
||||
"5m": 5,
|
||||
"15m": 15,
|
||||
"30m": 30,
|
||||
"1h": 60,
|
||||
"4h": 240,
|
||||
"1d": 1440,
|
||||
"1w": 10080,
|
||||
}
|
||||
|
||||
|
||||
class KrakenMarketData(MarketData):
|
||||
"""Public Kraken spot market data over REST (no API key required)."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL, timeout: float = 10.0) -> None:
|
||||
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _interval(timeframe: str) -> int:
|
||||
try:
|
||||
return INTERVALS[timeframe]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"unsupported timeframe '{timeframe}' for Kraken; use one of {sorted(INTERVALS)}"
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _result(payload: dict) -> dict:
|
||||
errors = payload.get("error") or []
|
||||
if errors:
|
||||
raise ValueError(f"kraken error: {errors}")
|
||||
return payload.get("result", {})
|
||||
|
||||
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
|
||||
response = await self._client.get(
|
||||
"/0/public/OHLC",
|
||||
params={"pair": symbol, "interval": self._interval(timeframe)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = self._result(response.json())
|
||||
rows = next((value for key, value in result.items() if key != "last"), [])
|
||||
candles = [
|
||||
Candle(
|
||||
ts=int(row[0]) * 1000,
|
||||
open=float(row[1]),
|
||||
high=float(row[2]),
|
||||
low=float(row[3]),
|
||||
close=float(row[4]),
|
||||
volume=float(row[6]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return candles[-limit:]
|
||||
|
||||
async def fetch_price(self, symbol: str) -> float:
|
||||
response = await self._client.get("/0/public/Ticker", params={"pair": symbol})
|
||||
response.raise_for_status()
|
||||
result = self._result(response.json())
|
||||
ticker = next(iter(result.values()))
|
||||
return float(ticker["c"][0])
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -45,6 +45,7 @@ class StrategyConfig(Base):
|
||||
__tablename__ = "strategy_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(80), default="")
|
||||
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
timeframe: Mapped[str] = mapped_column(String(16))
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import suppress
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Notifier(ABC):
|
||||
@abstractmethod
|
||||
async def send(self, level: str, message: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class TelegramNotifier(Notifier):
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
chat_id: str,
|
||||
base_url: str = "https://api.telegram.org",
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.chat_id = chat_id
|
||||
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
||||
|
||||
async def send(self, level: str, message: str) -> None:
|
||||
response = await self._client.post(
|
||||
f"/bot{self.token}/sendMessage",
|
||||
json={
|
||||
"chat_id": self.chat_id,
|
||||
"text": f"[{level.upper()}] {message}",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
class WebhookNotifier(Notifier):
|
||||
def __init__(self, url: str, timeout: float = 10.0) -> None:
|
||||
self.url = url
|
||||
self._client = httpx.AsyncClient(timeout=timeout)
|
||||
|
||||
async def send(self, level: str, message: str) -> None:
|
||||
response = await self._client.post(self.url, json={"level": level, "text": message})
|
||||
response.raise_for_status()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
class FanoutNotifier(Notifier):
|
||||
"""Sends to every configured channel, never letting one failure block others."""
|
||||
|
||||
def __init__(self, notifiers: list[Notifier]) -> None:
|
||||
self.notifiers = [notifier for notifier in notifiers if notifier is not None]
|
||||
|
||||
async def send(self, level: str, message: str) -> None:
|
||||
for notifier in self.notifiers:
|
||||
try:
|
||||
await notifier.send(level, message)
|
||||
except Exception as exc: # notifications must never break trading
|
||||
logger.warning("notification via %s failed: %s", type(notifier).__name__, exc)
|
||||
|
||||
async def close(self) -> None:
|
||||
for notifier in self.notifiers:
|
||||
with suppress(Exception):
|
||||
await notifier.close()
|
||||
|
||||
|
||||
def build_notifier(settings: Settings) -> Notifier | None:
|
||||
notifiers: list[Notifier] = []
|
||||
if settings.telegram_bot_token and settings.telegram_chat_id:
|
||||
notifiers.append(TelegramNotifier(settings.telegram_bot_token, settings.telegram_chat_id))
|
||||
if settings.notify_webhook_url:
|
||||
notifiers.append(WebhookNotifier(settings.notify_webhook_url))
|
||||
if not notifiers:
|
||||
return None
|
||||
return FanoutNotifier(notifiers)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
@@ -13,11 +14,15 @@ from app.engine.engine import TradingEngine
|
||||
from app.engine.strategies import get_strategy
|
||||
from app.market import build_market_data
|
||||
from app.market.base import MarketData
|
||||
from app.notify import Notifier, build_notifier
|
||||
from app.schemas import BacktestRequest, BacktestResult, StatusOut
|
||||
from app.store import Store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FeedFactory = Callable[[Settings], MarketData]
|
||||
BrokerFactory = Callable[[Settings], Broker]
|
||||
NotifierFactory = Callable[[Settings], Notifier | None]
|
||||
|
||||
|
||||
class Runtime:
|
||||
@@ -30,13 +35,19 @@ class Runtime:
|
||||
*,
|
||||
feed_factory: FeedFactory = build_market_data,
|
||||
broker_factory: BrokerFactory = build_broker,
|
||||
notifier: Notifier | None = None,
|
||||
notifier_factory: NotifierFactory = build_notifier,
|
||||
notify_levels: set[str] | None = None,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.store = store or Store()
|
||||
self._feed_factory = feed_factory
|
||||
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._subscribers: set[asyncio.Queue] = set()
|
||||
self._notify_tasks: set[asyncio.Task] = set()
|
||||
|
||||
# --- pub/sub ------------------------------------------------------
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
@@ -54,6 +65,19 @@ class Runtime:
|
||||
queue.get_nowait()
|
||||
with suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(payload)
|
||||
if payload.get("type") == "event":
|
||||
self._maybe_notify(payload["data"])
|
||||
|
||||
def _maybe_notify(self, event: dict[str, Any]) -> None:
|
||||
if self.notifier is None or event.get("level") not in self.notify_levels:
|
||||
return
|
||||
message = event.get("message", "")
|
||||
try:
|
||||
task = asyncio.create_task(self.notifier.send(event["level"], message))
|
||||
except RuntimeError: # no running loop (e.g. sync test context)
|
||||
return
|
||||
self._notify_tasks.add(task)
|
||||
task.add_done_callback(self._notify_tasks.discard)
|
||||
|
||||
# --- lifecycle ----------------------------------------------------
|
||||
async def start(
|
||||
@@ -90,6 +114,12 @@ class Runtime:
|
||||
return self.engine.status()
|
||||
return 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()
|
||||
|
||||
async def run_backtest(self, request: BacktestRequest) -> BacktestResult:
|
||||
symbol = request.symbol or self.settings.symbol
|
||||
timeframe = request.timeframe or self.settings.timeframe
|
||||
@@ -112,6 +142,8 @@ class Runtime:
|
||||
async def shutdown(self) -> None:
|
||||
if self.engine is not None:
|
||||
await self.engine.shutdown()
|
||||
if self.notifier is not None:
|
||||
await self.notifier.close()
|
||||
|
||||
def status(self) -> StatusOut:
|
||||
if self.engine is not None:
|
||||
|
||||
@@ -103,6 +103,35 @@ class StatusOut(BaseModel):
|
||||
open_positions: int
|
||||
last_tick: datetime | None
|
||||
error: str | None = None
|
||||
halted: bool = False
|
||||
halt_reason: str | None = None
|
||||
trades_today: int = 0
|
||||
day_pnl: float = 0.0
|
||||
drawdown_pct: float = 0.0
|
||||
|
||||
|
||||
class KillRequest(BaseModel):
|
||||
reason: str = "manual kill switch"
|
||||
|
||||
|
||||
class StrategyConfigIn(BaseModel):
|
||||
name: str = Field(default="", max_length=80)
|
||||
strategy_key: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StrategyConfigOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
strategy_key: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
params: dict[str, Any]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
|
||||
+27
-2
@@ -162,12 +162,37 @@ class Store:
|
||||
return list(reversed(rows))
|
||||
|
||||
def save_strategy_config(
|
||||
self, strategy_key: str, symbol: str, timeframe: str, params: dict
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
strategy_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
params: dict,
|
||||
) -> StrategyConfig:
|
||||
with self._session_factory() as session:
|
||||
config = StrategyConfig(
|
||||
strategy_key=strategy_key, symbol=symbol, timeframe=timeframe, params=params
|
||||
name=name,
|
||||
strategy_key=strategy_key,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
params=params,
|
||||
)
|
||||
session.add(config)
|
||||
session.commit()
|
||||
return config
|
||||
|
||||
def list_strategy_configs(self) -> list[StrategyConfig]:
|
||||
with self._session_factory() as session:
|
||||
return list(
|
||||
session.scalars(select(StrategyConfig).order_by(StrategyConfig.id.desc())).all()
|
||||
)
|
||||
|
||||
def delete_strategy_config(self, config_id: int) -> bool:
|
||||
with self._session_factory() as session:
|
||||
config = session.get(StrategyConfig, config_id)
|
||||
if config is None:
|
||||
return False
|
||||
session.delete(config)
|
||||
session.commit()
|
||||
return True
|
||||
Reference in new issue
Block a user