Crypto trading bot: engine, brokers, backtester, dashboard, deploy
FastAPI backend with a single-strategy async engine, paper and Binance brokers, pure-python indicators, a backtester that shares the engine's portfolio math, and a Vue dashboard with a live WebSocket feed. Adds the Gitea Actions CI/CD workflow that deploys to Traefik via docker_default.
This commit is contained in:
1 parent
98028248c2
commit
9c3bee87ac
73 files changed
+9759
-1
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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.models import OrderSide, Position, PositionStatus
|
||||
from app.runtime import Runtime
|
||||
from app.schemas import (
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
ClosePositionRequest,
|
||||
EquityPointOut,
|
||||
EventOut,
|
||||
ManualOrderRequest,
|
||||
OrderOut,
|
||||
PositionOut,
|
||||
StartRequest,
|
||||
StatusOut,
|
||||
StrategyOut,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
def get_runtime(request: Request) -> Runtime:
|
||||
return request.app.state.runtime
|
||||
|
||||
|
||||
RuntimeDep = Annotated[Runtime, Depends(get_runtime)]
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/strategies", response_model=list[StrategyOut])
|
||||
def strategies() -> list[StrategyOut]:
|
||||
return [strategy_spec(cls) for cls in list_strategies()]
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusOut)
|
||||
def status(runtime: RuntimeDep) -> StatusOut:
|
||||
return runtime.status()
|
||||
|
||||
|
||||
@router.post("/bot/start", response_model=StatusOut)
|
||||
async def start_bot(payload: StartRequest, runtime: RuntimeDep) -> StatusOut:
|
||||
try:
|
||||
return await runtime.start(
|
||||
payload.strategy_key,
|
||||
payload.params,
|
||||
symbol=payload.symbol,
|
||||
timeframe=payload.timeframe,
|
||||
starting_balance=payload.starting_balance,
|
||||
)
|
||||
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: RuntimeDep) -> StatusOut:
|
||||
return await runtime.stop()
|
||||
|
||||
|
||||
@router.post("/backtest", response_model=BacktestResult)
|
||||
async def run_backtest(payload: BacktestRequest, runtime: RuntimeDep) -> BacktestResult:
|
||||
try:
|
||||
return await runtime.run_backtest(payload)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"market data error: {exc}") from exc
|
||||
|
||||
|
||||
def _position_out(position: Position, price: float | None) -> PositionOut:
|
||||
out = PositionOut.model_validate(position)
|
||||
if position.status == PositionStatus.OPEN and price is not None:
|
||||
sign = 1 if position.side.value == "long" else -1
|
||||
out.unrealized_pnl = sign * (price - position.entry_price) * position.qty
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/positions", response_model=list[PositionOut])
|
||||
def positions(runtime: RuntimeDep, 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]:
|
||||
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]:
|
||||
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]:
|
||||
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:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
raise HTTPException(status_code=409, detail="engine is not running")
|
||||
side = OrderSide.BUY if payload.side == "buy" else OrderSide.SELL
|
||||
await engine.manual_order(side, payload.qty, payload.quote_amount, payload.reason)
|
||||
return engine.status()
|
||||
|
||||
|
||||
@router.post("/positions/{position_id}/close", response_model=StatusOut)
|
||||
async def close_position(
|
||||
position_id: int, payload: ClosePositionRequest, runtime: RuntimeDep
|
||||
) -> StatusOut:
|
||||
engine = runtime.engine
|
||||
if engine is None or not engine.running:
|
||||
raise HTTPException(status_code=409, detail="engine is not running")
|
||||
if engine.position is None or engine.position.id != position_id:
|
||||
raise HTTPException(status_code=404, detail="no open position with that id")
|
||||
await engine.close_current_position(payload.reason)
|
||||
return engine.status()
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
runtime: Runtime = websocket.app.state.runtime
|
||||
await websocket.accept()
|
||||
queue = runtime.subscribe()
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{"type": "status", "data": runtime.status().model_dump(mode="json")}
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
payload = await asyncio.wait_for(queue.get(), timeout=15.0)
|
||||
except TimeoutError:
|
||||
await websocket.send_json({"type": "ping"})
|
||||
continue
|
||||
await websocket.send_json(payload)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
runtime.unsubscribe(queue)
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.backtest.backtester import Backtester
|
||||
from app.backtest.metrics import compute_metrics, max_drawdown_pct, sharpe_ratio
|
||||
|
||||
__all__ = ["Backtester", "compute_metrics", "max_drawdown_pct", "sharpe_ratio"]
|
||||
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.backtest.metrics import compute_metrics
|
||||
from app.broker.paper import PaperBroker
|
||||
from app.engine.portfolio import (
|
||||
OpenPosition,
|
||||
account_equity,
|
||||
apply_close,
|
||||
apply_open,
|
||||
order_qty,
|
||||
realized_pnl,
|
||||
risk_exit,
|
||||
)
|
||||
from app.engine.strategies.base import Strategy, StrategyContext
|
||||
from app.models import OrderSide, PositionSide
|
||||
from app.schemas import BacktestPointOut, BacktestResult, BacktestTradeOut, Candle
|
||||
|
||||
|
||||
def _open_side(side: PositionSide) -> OrderSide:
|
||||
return OrderSide.BUY if side == PositionSide.LONG else OrderSide.SELL
|
||||
|
||||
|
||||
def _close_side(side: PositionSide) -> OrderSide:
|
||||
return OrderSide.SELL if side == PositionSide.LONG else OrderSide.BUY
|
||||
|
||||
|
||||
class Backtester:
|
||||
"""Runs a strategy bar-by-bar over historical candles.
|
||||
|
||||
Reuses the very same ``Strategy``/``StrategyContext`` interface and the same
|
||||
portfolio math as the live engine, so results carry over to paper/live.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
fee_bps: float = 10.0,
|
||||
slippage_bps: float = 5.0,
|
||||
starting_balance: float = 10_000.0,
|
||||
) -> None:
|
||||
self.broker = PaperBroker(fee_bps=fee_bps, slippage_bps=slippage_bps)
|
||||
self.starting_balance = starting_balance
|
||||
|
||||
def run(
|
||||
self,
|
||||
candles: Sequence[Candle],
|
||||
strategy: Strategy,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
) -> BacktestResult:
|
||||
starting = self.starting_balance
|
||||
cash = starting
|
||||
position: OpenPosition | None = None
|
||||
trades: list[BacktestTradeOut] = []
|
||||
curve: list[BacktestPointOut] = []
|
||||
error: str | None = None
|
||||
first_close = candles[0].close if candles else 0.0
|
||||
|
||||
def close_at(level: float, ts: int, reason: str) -> None:
|
||||
nonlocal cash, position
|
||||
assert position is not None
|
||||
fill = self.broker.fill(symbol, _close_side(position.side), position.qty, level)
|
||||
cash = apply_close(cash, position.side, fill.qty, fill.price, fill.fee)
|
||||
pnl = realized_pnl(
|
||||
position.side,
|
||||
position.entry_price,
|
||||
fill.price,
|
||||
position.qty,
|
||||
position.entry_fee,
|
||||
fill.fee,
|
||||
)
|
||||
cost = position.entry_price * position.qty
|
||||
trades.append(
|
||||
BacktestTradeOut(
|
||||
side=position.side.value,
|
||||
entry_ts=position.entry_ts,
|
||||
exit_ts=ts,
|
||||
entry_price=position.entry_price,
|
||||
exit_price=fill.price,
|
||||
qty=position.qty,
|
||||
pnl=pnl,
|
||||
return_pct=(pnl / cost * 100) if cost else 0.0,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
position = None
|
||||
|
||||
try:
|
||||
for i, candle in enumerate(candles):
|
||||
mark = candle.close
|
||||
if position is not None:
|
||||
hit = risk_exit(
|
||||
position.side,
|
||||
position.stop_loss,
|
||||
position.take_profit,
|
||||
candle.high,
|
||||
candle.low,
|
||||
)
|
||||
if hit is not None:
|
||||
close_at(hit[1], candle.ts, hit[0])
|
||||
|
||||
equity = (
|
||||
account_equity(cash, position.side, position.qty, mark) if position else cash
|
||||
)
|
||||
ctx = StrategyContext(candles[: i + 1], position, equity, cash)
|
||||
strategy.on_candle(ctx)
|
||||
|
||||
for intent in ctx.take_intents():
|
||||
if intent.action == "close":
|
||||
if position is not None:
|
||||
close_at(mark, candle.ts, intent.reason or "strategy close")
|
||||
elif intent.action in {"enter_long", "enter_short"} and position is None:
|
||||
side = (
|
||||
PositionSide.LONG
|
||||
if intent.action == "enter_long"
|
||||
else PositionSide.SHORT
|
||||
)
|
||||
size = intent.size if intent.size is not None else 1.0
|
||||
qty = order_qty(cash, size, side, cash, mark)
|
||||
if qty <= 0:
|
||||
continue
|
||||
fill = self.broker.fill(symbol, _open_side(side), qty, mark)
|
||||
cash = apply_open(cash, side, fill.qty, fill.price, fill.fee)
|
||||
position = OpenPosition(
|
||||
side=side,
|
||||
qty=fill.qty,
|
||||
entry_price=fill.price,
|
||||
entry_fee=fill.fee,
|
||||
stop_loss=intent.stop_loss,
|
||||
take_profit=intent.take_profit,
|
||||
entry_ts=candle.ts,
|
||||
)
|
||||
|
||||
equity = (
|
||||
account_equity(cash, position.side, position.qty, mark) if position else cash
|
||||
)
|
||||
curve.append(
|
||||
BacktestPointOut(
|
||||
ts=candle.ts,
|
||||
equity=equity,
|
||||
buy_hold=starting * (mark / first_close) if first_close else starting,
|
||||
price=mark,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # surface strategy/config errors in the result
|
||||
error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
# Force-close any open position at the last close so trades are realized.
|
||||
if position is not None and candles and error is None:
|
||||
last = candles[-1]
|
||||
close_at(last.close, last.ts, "end of data")
|
||||
if curve:
|
||||
curve[-1] = BacktestPointOut(
|
||||
ts=last.ts,
|
||||
equity=cash,
|
||||
buy_hold=curve[-1].buy_hold,
|
||||
price=last.close,
|
||||
)
|
||||
|
||||
return BacktestResult(
|
||||
strategy_key=strategy.key,
|
||||
strategy_name=strategy.name or strategy.key,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
starting_balance=starting,
|
||||
metrics=compute_metrics(curve, trades, starting, timeframe),
|
||||
equity_curve=curve,
|
||||
trades=trades,
|
||||
error=error,
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.schemas import BacktestMetricsOut, BacktestPointOut, BacktestTradeOut
|
||||
|
||||
# Bars per year for annualising the Sharpe ratio. Unknown timeframes -> None.
|
||||
BARS_PER_YEAR: dict[str, float] = {
|
||||
"1m": 525_600,
|
||||
"3m": 175_200,
|
||||
"5m": 105_120,
|
||||
"15m": 35_040,
|
||||
"30m": 17_520,
|
||||
"1h": 8_760,
|
||||
"2h": 4_380,
|
||||
"4h": 2_190,
|
||||
"6h": 1_460,
|
||||
"12h": 730,
|
||||
"1d": 365,
|
||||
"3d": 121,
|
||||
"1w": 52,
|
||||
}
|
||||
|
||||
|
||||
def max_drawdown_pct(equities: Sequence[float]) -> float:
|
||||
peak = equities[0] if equities else 0.0
|
||||
worst = 0.0
|
||||
for value in equities:
|
||||
peak = max(peak, value)
|
||||
if peak > 0:
|
||||
worst = max(worst, (peak - value) / peak)
|
||||
return worst * 100
|
||||
|
||||
|
||||
def sharpe_ratio(equities: Sequence[float], timeframe: str) -> float | None:
|
||||
if len(equities) < 3:
|
||||
return None
|
||||
returns = [
|
||||
equities[i] / equities[i - 1] - 1 for i in range(1, len(equities)) if equities[i - 1] > 0
|
||||
]
|
||||
if len(returns) < 2:
|
||||
return None
|
||||
mean = sum(returns) / len(returns)
|
||||
variance = sum((r - mean) ** 2 for r in returns) / (len(returns) - 1)
|
||||
std = variance**0.5
|
||||
periods = BARS_PER_YEAR.get(timeframe)
|
||||
if std == 0 or not periods:
|
||||
return None
|
||||
return mean / std * (periods**0.5)
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
curve: Sequence[BacktestPointOut],
|
||||
trades: Sequence[BacktestTradeOut],
|
||||
starting_balance: float,
|
||||
timeframe: str,
|
||||
) -> BacktestMetricsOut:
|
||||
equities = [point.equity for point in curve] or [starting_balance]
|
||||
final_equity = equities[-1]
|
||||
total_return = (final_equity / starting_balance - 1) * 100 if starting_balance else 0.0
|
||||
buy_hold = (
|
||||
(curve[-1].buy_hold / starting_balance - 1) * 100 if curve and starting_balance else 0.0
|
||||
)
|
||||
|
||||
returns = [trade.return_pct for trade in trades]
|
||||
gross_profit = sum(trade.pnl for trade in trades if trade.pnl > 0)
|
||||
gross_loss = -sum(trade.pnl for trade in trades if trade.pnl < 0)
|
||||
wins = sum(1 for trade in trades if trade.pnl > 0)
|
||||
|
||||
return BacktestMetricsOut(
|
||||
initial_equity=starting_balance,
|
||||
final_equity=final_equity,
|
||||
total_return_pct=total_return,
|
||||
buy_hold_return_pct=buy_hold,
|
||||
max_drawdown_pct=max_drawdown_pct(equities),
|
||||
num_trades=len(trades),
|
||||
win_rate_pct=(wins / len(trades) * 100) if trades else 0.0,
|
||||
profit_factor=(gross_profit / gross_loss) if gross_loss > 0 else None,
|
||||
avg_trade_pct=(sum(returns) / len(returns)) if returns else 0.0,
|
||||
best_trade_pct=max(returns) if returns else 0.0,
|
||||
worst_trade_pct=min(returns) if returns else 0.0,
|
||||
sharpe=sharpe_ratio(equities, timeframe),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.broker.base import Broker, Fill
|
||||
from app.broker.binance import TESTNET_URL, BinanceBroker
|
||||
from app.broker.paper import PaperBroker
|
||||
from app.config import Settings
|
||||
from app.market.binance import BASE_URL
|
||||
|
||||
|
||||
def build_broker(settings: Settings) -> Broker:
|
||||
if settings.mode == "live":
|
||||
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)
|
||||
|
||||
|
||||
__all__ = ["Broker", "Fill", "PaperBroker", "build_broker"]
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import OrderSide
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Fill:
|
||||
symbol: str
|
||||
side: OrderSide
|
||||
qty: float
|
||||
price: float
|
||||
fee: float
|
||||
ts: datetime
|
||||
|
||||
@property
|
||||
def notional(self) -> float:
|
||||
return self.qty * self.price
|
||||
|
||||
|
||||
class Broker(ABC):
|
||||
"""Executes market orders. Paper and live brokers share this interface."""
|
||||
|
||||
@abstractmethod
|
||||
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.broker.base import Broker, Fill
|
||||
from app.market.binance import BASE_URL, to_exchange_symbol
|
||||
from app.models import OrderSide, utcnow
|
||||
|
||||
TESTNET_URL = "https://testnet.binance.vision"
|
||||
|
||||
|
||||
class BinanceBroker(Broker):
|
||||
"""Live Binance spot market-order execution (requires API credentials)."""
|
||||
|
||||
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("Binance broker requires EXCHANGE_API_KEY and EXCHANGE_API_SECRET")
|
||||
self.api_key = api_key
|
||||
self.api_secret = api_secret.encode()
|
||||
self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
||||
|
||||
def _sign(self, params: dict) -> dict:
|
||||
params = {**params, "timestamp": int(time.time() * 1000), "recvWindow": 5000}
|
||||
query = urlencode(params)
|
||||
signature = hmac.new(self.api_secret, query.encode(), hashlib.sha256).hexdigest()
|
||||
return {**params, "signature": signature}
|
||||
|
||||
async def _market_order(self, symbol: str, side: OrderSide, qty: float) -> Fill:
|
||||
params = self._sign(
|
||||
{
|
||||
"symbol": to_exchange_symbol(symbol),
|
||||
"side": side.value.upper(),
|
||||
"type": "MARKET",
|
||||
"quantity": f"{qty:.8f}".rstrip("0").rstrip("."),
|
||||
}
|
||||
)
|
||||
response = await self._client.post(
|
||||
"/api/v3/order",
|
||||
params=params,
|
||||
headers={"X-MBX-APIKEY": self.api_key},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
executed = float(data.get("executedQty", qty))
|
||||
quote = float(data.get("cummulativeQuoteQty", 0.0))
|
||||
price = quote / executed if executed else 0.0
|
||||
fee = sum(float(fill.get("commission", 0.0)) for fill in data.get("fills", []))
|
||||
return Fill(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=executed,
|
||||
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()
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.broker.base import Broker, Fill
|
||||
from app.models import OrderSide, utcnow
|
||||
|
||||
|
||||
class PaperBroker(Broker):
|
||||
"""Simulated fills against a reference price, with fees and slippage.
|
||||
|
||||
Buys fill slightly above and sells slightly below the reference price to
|
||||
approximate adverse slippage.
|
||||
"""
|
||||
|
||||
def __init__(self, fee_bps: float = 10.0, slippage_bps: float = 5.0) -> None:
|
||||
self.fee_bps = fee_bps
|
||||
self.slippage_bps = slippage_bps
|
||||
|
||||
def _fee(self, notional: float) -> float:
|
||||
return abs(notional) * self.fee_bps / 10_000
|
||||
|
||||
def fill(self, symbol: str, side: OrderSide, qty: float, ref_price: float) -> Fill:
|
||||
"""Synchronous fill used by the backtester (same model as buy/sell)."""
|
||||
direction = 1 if side == OrderSide.BUY else -1
|
||||
price = ref_price * (1 + direction * self.slippage_bps / 10_000)
|
||||
return Fill(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=qty,
|
||||
price=price,
|
||||
fee=self._fee(qty * price),
|
||||
ts=utcnow(),
|
||||
)
|
||||
|
||||
async def buy(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
return self.fill(symbol, OrderSide.BUY, qty, ref_price)
|
||||
|
||||
async def sell(self, symbol: str, qty: float, ref_price: float) -> Fill:
|
||||
return self.fill(symbol, OrderSide.SELL, qty, ref_price)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DATA_DIR = REPO_ROOT / "data"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=(REPO_ROOT / ".env", ".env"),
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_name: str = "Crypto Trader"
|
||||
env: str = "dev"
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
database_url: str = "sqlite:///./data/trader.db"
|
||||
|
||||
mode: str = "paper"
|
||||
exchange: str = "binance"
|
||||
symbol: str = "BTC/USDT"
|
||||
timeframe: str = "1m"
|
||||
poll_interval: float = 5.0
|
||||
candle_limit: int = 200
|
||||
|
||||
starting_balance: float = 10_000.0
|
||||
fee_bps: float = 10.0
|
||||
slippage_bps: float = 5.0
|
||||
|
||||
exchange_api_key: str = ""
|
||||
exchange_api_secret: str = ""
|
||||
exchange_sandbox: bool = False
|
||||
|
||||
cors_origins: str = "*"
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def _validate_mode(cls, value: str) -> str:
|
||||
value = value.lower().strip()
|
||||
if value not in {"paper", "live"}:
|
||||
raise ValueError("mode must be 'paper' or 'live'")
|
||||
return value
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def is_live(self) -> bool:
|
||||
return self.mode == "live"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
settings = Settings()
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return settings
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import REPO_ROOT, get_settings
|
||||
from app.models import Base
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
|
||||
def _normalize_url(url: str) -> tuple[str, dict]:
|
||||
connect_args: dict = {}
|
||||
if url.startswith("sqlite"):
|
||||
prefix, _, path = url.partition("sqlite:///")
|
||||
if path and not path.startswith("/"):
|
||||
url = f"{prefix}sqlite:///{REPO_ROOT / path}"
|
||||
connect_args = {"check_same_thread": False}
|
||||
return url, connect_args
|
||||
|
||||
|
||||
_url, _connect_args = _normalize_url(_settings.database_url)
|
||||
engine = create_engine(_url, connect_args=_connect_args, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_session() -> Iterator[Session]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
Whitespace-only changes.
@@ -0,0 +1,390 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.broker.base import Broker
|
||||
from app.config import Settings
|
||||
from app.engine.portfolio import (
|
||||
account_equity,
|
||||
apply_close,
|
||||
apply_open,
|
||||
order_qty,
|
||||
realized_pnl,
|
||||
risk_exit,
|
||||
unrealized_pnl,
|
||||
)
|
||||
from app.engine.strategies import Strategy, get_strategy
|
||||
from app.engine.strategies.base import StrategyContext
|
||||
from app.market.base import MarketData
|
||||
from app.models import OrderSide, Position, PositionSide
|
||||
from app.schemas import Candle, EventOut, StatusOut
|
||||
from app.store import Store
|
||||
|
||||
Publish = Callable[[dict[str, Any]], None]
|
||||
|
||||
|
||||
class TradingEngine:
|
||||
"""Single-strategy trading loop.
|
||||
|
||||
The engine owns portfolio state (cash, open position), calls the strategy
|
||||
once per candle, turns emitted intents into orders, and enforces stop-loss /
|
||||
take-profit levels.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
feed: MarketData,
|
||||
broker: Broker,
|
||||
store: Store,
|
||||
settings: Settings,
|
||||
) -> None:
|
||||
self.feed = feed
|
||||
self.broker = broker
|
||||
self.store = store
|
||||
self.settings = settings
|
||||
|
||||
self.running = False
|
||||
self.error: str | None = None
|
||||
self.last_tick: datetime | None = None
|
||||
self.last_price: float | None = None
|
||||
self.cash: float = settings.starting_balance
|
||||
self.starting_balance: float = settings.starting_balance
|
||||
self.realized_pnl: float = 0.0
|
||||
self.position: Position | None = None
|
||||
self.strategy: Strategy | None = None
|
||||
self.strategy_key: str | None = None
|
||||
self.symbol: str = settings.symbol
|
||||
self.timeframe: str = settings.timeframe
|
||||
|
||||
self._task: asyncio.Task | None = None
|
||||
self._publish_cb: Publish | None = None
|
||||
|
||||
# --- lifecycle ----------------------------------------------------
|
||||
def set_publisher(self, publish: Publish | None) -> None:
|
||||
self._publish_cb = publish
|
||||
|
||||
async def start(
|
||||
self,
|
||||
strategy_key: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
timeframe: str | None = None,
|
||||
starting_balance: float | None = None,
|
||||
) -> None:
|
||||
if self.running:
|
||||
raise RuntimeError("engine already running")
|
||||
|
||||
strategy_cls = get_strategy(strategy_key)
|
||||
self.strategy = strategy_cls(params)
|
||||
self.strategy_key = strategy_key
|
||||
self.symbol = symbol or self.settings.symbol
|
||||
self.timeframe = timeframe or self.settings.timeframe
|
||||
self.starting_balance = float(starting_balance or self.settings.starting_balance)
|
||||
|
||||
self.store.abandon_open_positions(strategy_key, reason="reset on engine start")
|
||||
self.position = None
|
||||
self.cash = self.starting_balance
|
||||
self.realized_pnl = 0.0
|
||||
self.last_price = None
|
||||
self.error = None
|
||||
self.last_tick = None
|
||||
|
||||
self.running = True
|
||||
self._emit("info", f"Started {strategy_cls.name or strategy_key} on {self.symbol}")
|
||||
self._broadcast_status()
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self.running:
|
||||
return
|
||||
self.running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await self._task
|
||||
self._task = None
|
||||
self._emit("info", "Engine stopped")
|
||||
self._broadcast_status()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
await self.stop()
|
||||
await self.feed.close()
|
||||
await self.broker.close()
|
||||
|
||||
# --- loop ---------------------------------------------------------
|
||||
async def _run(self) -> None:
|
||||
while self.running:
|
||||
try:
|
||||
await self.tick()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
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}")
|
||||
await asyncio.sleep(self.settings.poll_interval)
|
||||
|
||||
async def tick(self) -> None:
|
||||
candles = await self.feed.fetch_candles(
|
||||
self.symbol, self.timeframe, self.settings.candle_limit
|
||||
)
|
||||
if not candles:
|
||||
return
|
||||
self.last_price = candles[-1].close
|
||||
self.last_tick = datetime.now(UTC)
|
||||
self.error = None
|
||||
|
||||
if self.position is not None:
|
||||
await self._check_risk(candles[-1])
|
||||
|
||||
if self.strategy is not None:
|
||||
ctx = StrategyContext(
|
||||
candles=candles,
|
||||
position=self.position,
|
||||
equity=self.equity,
|
||||
cash=self.cash,
|
||||
)
|
||||
try:
|
||||
self.strategy.on_candle(ctx)
|
||||
except Exception as exc:
|
||||
self.error = f"strategy error: {type(exc).__name__}: {exc}"
|
||||
self._emit("error", self.error)
|
||||
await self._process_intents(ctx)
|
||||
|
||||
self.store.record_equity(self.equity, self.cash, self.last_price)
|
||||
self._broadcast_status()
|
||||
|
||||
# --- portfolio helpers -------------------------------------------
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
if self.position is None:
|
||||
return self.cash
|
||||
return account_equity(
|
||||
self.cash, self.position.side, self.position.qty, self.last_price or 0.0
|
||||
)
|
||||
|
||||
@property
|
||||
def unrealized_pnl(self) -> float:
|
||||
if self.position is None or self.last_price is None:
|
||||
return 0.0
|
||||
return unrealized_pnl(
|
||||
self.position.side, self.position.entry_price, self.last_price, self.position.qty
|
||||
)
|
||||
|
||||
async def _process_intents(self, ctx: StrategyContext) -> None:
|
||||
for intent in ctx.take_intents():
|
||||
if intent.action == "close":
|
||||
if self.position is not None:
|
||||
await self._close_position(intent.reason or "strategy close")
|
||||
elif intent.action in {"enter_long", "enter_short"}:
|
||||
if self.position is not None:
|
||||
continue
|
||||
side = PositionSide.LONG if intent.action == "enter_long" else PositionSide.SHORT
|
||||
await self._open_position(
|
||||
side,
|
||||
size=intent.size if intent.size is not None else 1.0,
|
||||
stop_loss=intent.stop_loss,
|
||||
take_profit=intent.take_profit,
|
||||
reason=intent.reason or intent.action,
|
||||
)
|
||||
|
||||
async def _open_position(
|
||||
self,
|
||||
side: PositionSide,
|
||||
*,
|
||||
size: float,
|
||||
stop_loss: float | None,
|
||||
take_profit: float | None,
|
||||
reason: str,
|
||||
qty_override: float | None = None,
|
||||
) -> None:
|
||||
price = self.last_price
|
||||
if not price or price <= 0:
|
||||
return
|
||||
|
||||
if qty_override is not None:
|
||||
qty = qty_override
|
||||
else:
|
||||
qty = order_qty(self.equity, size, side, self.cash, price)
|
||||
if qty <= 0:
|
||||
return
|
||||
|
||||
fill = (
|
||||
await self.broker.buy(self.symbol, qty, price)
|
||||
if side == PositionSide.LONG
|
||||
else await self.broker.sell(self.symbol, qty, price)
|
||||
)
|
||||
|
||||
self.cash = apply_open(self.cash, side, fill.qty, fill.price, fill.fee)
|
||||
|
||||
self.position = self.store.create_position(
|
||||
strategy_key=self.strategy_key or "manual",
|
||||
symbol=self.symbol,
|
||||
side=side,
|
||||
qty=fill.qty,
|
||||
entry_price=fill.price,
|
||||
entry_fee=fill.fee,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
)
|
||||
self.store.record_order(
|
||||
position_id=self.position.id,
|
||||
strategy_key=self.position.strategy_key,
|
||||
symbol=self.symbol,
|
||||
side=fill.side,
|
||||
qty=fill.qty,
|
||||
price=fill.price,
|
||||
fee=fill.fee,
|
||||
reason=reason,
|
||||
)
|
||||
self._emit(
|
||||
"trade",
|
||||
f"Opened {side.value} {fill.qty:.6f} {self.symbol} @ {fill.price:.4f} ({reason})",
|
||||
)
|
||||
|
||||
async def _close_position(self, reason: str) -> None:
|
||||
position = self.position
|
||||
price = self.last_price
|
||||
if position is None or not price:
|
||||
return
|
||||
|
||||
fill = (
|
||||
await self.broker.sell(self.symbol, position.qty, price)
|
||||
if position.side == PositionSide.LONG
|
||||
else await self.broker.buy(self.symbol, position.qty, price)
|
||||
)
|
||||
|
||||
self.cash = apply_close(self.cash, position.side, fill.qty, fill.price, fill.fee)
|
||||
realized = realized_pnl(
|
||||
position.side,
|
||||
position.entry_price,
|
||||
fill.price,
|
||||
position.qty,
|
||||
position.entry_fee,
|
||||
fill.fee,
|
||||
)
|
||||
self.realized_pnl += realized
|
||||
|
||||
self.store.close_position(
|
||||
position,
|
||||
exit_price=fill.price,
|
||||
exit_fee=fill.fee,
|
||||
realized_pnl=realized,
|
||||
)
|
||||
self.store.record_order(
|
||||
position_id=position.id,
|
||||
strategy_key=position.strategy_key,
|
||||
symbol=self.symbol,
|
||||
side=fill.side,
|
||||
qty=fill.qty,
|
||||
price=fill.price,
|
||||
fee=fill.fee,
|
||||
reason=reason,
|
||||
)
|
||||
self.position = None
|
||||
self._emit(
|
||||
"trade",
|
||||
f"Closed {position.side.value} {position.qty:.6f} {self.symbol} "
|
||||
f"@ {fill.price:.4f} pnl={realized:.2f} ({reason})",
|
||||
)
|
||||
|
||||
async def _check_risk(self, candle: Candle) -> None:
|
||||
position = self.position
|
||||
if position is None:
|
||||
return
|
||||
hit = risk_exit(
|
||||
position.side,
|
||||
position.stop_loss,
|
||||
position.take_profit,
|
||||
candle.high,
|
||||
candle.low,
|
||||
)
|
||||
if hit is not None:
|
||||
reason, level = hit
|
||||
self.last_price = level
|
||||
await self._close_position(reason)
|
||||
|
||||
# --- manual controls ----------------------------------------------
|
||||
async def manual_order(
|
||||
self, side: OrderSide, qty: float | None, quote_amount: float | None, reason: str
|
||||
) -> None:
|
||||
price = self.last_price or await self.feed.fetch_price(self.symbol)
|
||||
self.last_price = price
|
||||
if qty is None:
|
||||
notional = quote_amount if quote_amount is not None else self.cash * 0.99
|
||||
qty = notional / price
|
||||
if qty <= 0:
|
||||
return
|
||||
if side == OrderSide.BUY:
|
||||
if self.position is not None:
|
||||
return
|
||||
await self._open_position(
|
||||
PositionSide.LONG,
|
||||
size=0.0,
|
||||
stop_loss=None,
|
||||
take_profit=None,
|
||||
reason=reason,
|
||||
qty_override=qty,
|
||||
)
|
||||
else:
|
||||
if self.position is not None:
|
||||
await self._close_position(reason or "manual close")
|
||||
else:
|
||||
await self._open_position(
|
||||
PositionSide.SHORT,
|
||||
size=0.0,
|
||||
stop_loss=None,
|
||||
take_profit=None,
|
||||
reason=reason,
|
||||
qty_override=qty,
|
||||
)
|
||||
|
||||
async def close_current_position(self, reason: str = "manual") -> None:
|
||||
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(reason)
|
||||
|
||||
# --- status / events ----------------------------------------------
|
||||
def status(self) -> StatusOut:
|
||||
strategy_name = None
|
||||
if self.strategy is not None:
|
||||
strategy_name = self.strategy.name or self.strategy.key
|
||||
return StatusOut(
|
||||
running=self.running,
|
||||
mode=self.settings.mode,
|
||||
exchange=self.settings.exchange,
|
||||
symbol=self.symbol,
|
||||
timeframe=self.timeframe,
|
||||
strategy_key=self.strategy_key,
|
||||
strategy_name=strategy_name,
|
||||
params=dict(self.strategy.params) if self.strategy else {},
|
||||
price=self.last_price,
|
||||
cash=self.cash,
|
||||
equity=self.equity,
|
||||
starting_balance=self.starting_balance,
|
||||
realized_pnl=self.realized_pnl,
|
||||
unrealized_pnl=self.unrealized_pnl,
|
||||
total_pnl=self.equity - self.starting_balance,
|
||||
open_positions=1 if self.position else 0,
|
||||
last_tick=self.last_tick,
|
||||
error=self.error,
|
||||
)
|
||||
|
||||
def _emit(self, level: str, message: str) -> None:
|
||||
event = self.store.record_event(level, message, self.strategy_key)
|
||||
self._publish(
|
||||
{"type": "event", "data": EventOut.model_validate(event).model_dump(mode="json")}
|
||||
)
|
||||
|
||||
def _broadcast_status(self) -> None:
|
||||
self._publish({"type": "status", "data": self.status().model_dump(mode="json")})
|
||||
|
||||
def _publish(self, payload: dict[str, Any]) -> None:
|
||||
if self._publish_cb is not None:
|
||||
self._publish_cb(payload)
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.schemas import Candle
|
||||
|
||||
Number = float | None
|
||||
|
||||
|
||||
def sma(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
window_sum = 0.0
|
||||
for i, value in enumerate(values):
|
||||
window_sum += value
|
||||
if i >= period:
|
||||
window_sum -= values[i - period]
|
||||
if i >= period - 1:
|
||||
out[i] = window_sum / period
|
||||
return out
|
||||
|
||||
|
||||
def ema(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
if len(values) < period:
|
||||
return out
|
||||
multiplier = 2 / (period + 1)
|
||||
seed = sum(values[:period]) / period
|
||||
out[period - 1] = seed
|
||||
prev = seed
|
||||
for i in range(period, len(values)):
|
||||
prev = (values[i] - prev) * multiplier + prev
|
||||
out[i] = prev
|
||||
return out
|
||||
|
||||
|
||||
def rsi(values: Sequence[float], period: int = 14) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
if len(values) <= period:
|
||||
return out
|
||||
gains = 0.0
|
||||
losses = 0.0
|
||||
for i in range(1, period + 1):
|
||||
change = values[i] - values[i - 1]
|
||||
gains += max(change, 0.0)
|
||||
losses += max(-change, 0.0)
|
||||
avg_gain = gains / period
|
||||
avg_loss = losses / period
|
||||
out[period] = _rsi_value(avg_gain, avg_loss)
|
||||
for i in range(period + 1, len(values)):
|
||||
change = values[i] - values[i - 1]
|
||||
gain = max(change, 0.0)
|
||||
loss = max(-change, 0.0)
|
||||
avg_gain = (avg_gain * (period - 1) + gain) / period
|
||||
avg_loss = (avg_loss * (period - 1) + loss) / period
|
||||
out[i] = _rsi_value(avg_gain, avg_loss)
|
||||
return out
|
||||
|
||||
|
||||
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def true_range(candles: Sequence[Candle]) -> list[float]:
|
||||
out: list[float] = []
|
||||
for i, candle in enumerate(candles):
|
||||
if i == 0:
|
||||
out.append(candle.high - candle.low)
|
||||
continue
|
||||
prev_close = candles[i - 1].close
|
||||
out.append(
|
||||
max(
|
||||
candle.high - candle.low,
|
||||
abs(candle.high - prev_close),
|
||||
abs(candle.low - prev_close),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def atr(candles: Sequence[Candle], period: int = 14) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
tr = true_range(candles)
|
||||
out: list[Number] = [None] * len(tr)
|
||||
if len(tr) < period:
|
||||
return out
|
||||
prev = sum(tr[:period]) / period
|
||||
out[period - 1] = prev
|
||||
for i in range(period, len(tr)):
|
||||
prev = (prev * (period - 1) + tr[i]) / period
|
||||
out[i] = prev
|
||||
return out
|
||||
|
||||
|
||||
def rolling_std(values: Sequence[float], period: int) -> list[Number]:
|
||||
if period <= 0:
|
||||
raise ValueError("period must be positive")
|
||||
out: list[Number] = [None] * len(values)
|
||||
for i in range(period - 1, len(values)):
|
||||
window = values[i - period + 1 : i + 1]
|
||||
mean = sum(window) / period
|
||||
variance = sum((x - mean) ** 2 for x in window) / period
|
||||
out[i] = variance**0.5
|
||||
return out
|
||||
|
||||
|
||||
def last(series: Sequence[Number]) -> Number:
|
||||
for value in reversed(series):
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models import PositionSide
|
||||
|
||||
# All functions here are pure and shared by the live engine and the backtester
|
||||
# so both compute cash, P&L and risk exits identically.
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenPosition:
|
||||
"""Lightweight position used by the backtester (the engine uses a DB row)."""
|
||||
|
||||
side: PositionSide
|
||||
qty: float
|
||||
entry_price: float
|
||||
entry_fee: float = 0.0
|
||||
stop_loss: float | None = None
|
||||
take_profit: float | None = None
|
||||
entry_ts: int = 0
|
||||
|
||||
|
||||
def apply_open(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
|
||||
"""Cash after opening a position (buying long / selling short)."""
|
||||
notional = qty * price
|
||||
if side == PositionSide.LONG:
|
||||
return cash - notional - fee
|
||||
return cash + notional - fee
|
||||
|
||||
|
||||
def apply_close(cash: float, side: PositionSide, qty: float, price: float, fee: float) -> float:
|
||||
"""Cash after closing a position (selling long / buying back short)."""
|
||||
notional = qty * price
|
||||
if side == PositionSide.LONG:
|
||||
return cash + notional - fee
|
||||
return cash - notional - fee
|
||||
|
||||
|
||||
def realized_pnl(
|
||||
side: PositionSide,
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
qty: float,
|
||||
entry_fee: float,
|
||||
exit_fee: float,
|
||||
) -> float:
|
||||
gross = (
|
||||
(exit_price - entry_price) * qty
|
||||
if side == PositionSide.LONG
|
||||
else (entry_price - exit_price) * qty
|
||||
)
|
||||
return gross - entry_fee - exit_fee
|
||||
|
||||
|
||||
def unrealized_pnl(side: PositionSide, entry_price: float, mark: float, qty: float) -> float:
|
||||
if side == PositionSide.LONG:
|
||||
return (mark - entry_price) * qty
|
||||
return (entry_price - mark) * qty
|
||||
|
||||
|
||||
def position_value(side: PositionSide, qty: float, mark: float) -> float:
|
||||
"""Mark-to-market value of the position as an asset (negative for shorts)."""
|
||||
return qty * mark if side == PositionSide.LONG else -qty * mark
|
||||
|
||||
|
||||
def account_equity(cash: float, side: PositionSide, qty: float, mark: float) -> float:
|
||||
return cash + position_value(side, qty, mark)
|
||||
|
||||
|
||||
def order_qty(equity: float, size: float, side: PositionSide, cash: float, price: float) -> float:
|
||||
"""Position size as a fraction of equity, capped by available cash for longs."""
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
size = max(0.0, min(size, 1.0))
|
||||
notional = equity * size
|
||||
if side == PositionSide.LONG:
|
||||
notional = min(notional, cash * 0.99)
|
||||
return notional / price
|
||||
|
||||
|
||||
def risk_exit(
|
||||
side: PositionSide,
|
||||
stop_loss: float | None,
|
||||
take_profit: float | None,
|
||||
high: float,
|
||||
low: float,
|
||||
) -> tuple[str, float] | None:
|
||||
"""Return (reason, exit_price) if a stop/take level was hit within the bar."""
|
||||
if side == PositionSide.LONG:
|
||||
if stop_loss is not None and low <= stop_loss:
|
||||
return "stop loss", stop_loss
|
||||
if take_profit is not None and high >= take_profit:
|
||||
return "take profit", take_profit
|
||||
else:
|
||||
if stop_loss is not None and high >= stop_loss:
|
||||
return "stop loss", stop_loss
|
||||
if take_profit is not None and low <= take_profit:
|
||||
return "take profit", take_profit
|
||||
return None
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
|
||||
from app.engine.strategies.base import (
|
||||
Strategy,
|
||||
StrategyContext,
|
||||
get_strategy,
|
||||
list_strategies,
|
||||
param_specs,
|
||||
register,
|
||||
strategy_spec,
|
||||
)
|
||||
|
||||
|
||||
def discover() -> None:
|
||||
"""Import every module in this package so its strategies self-register."""
|
||||
for module_info in pkgutil.iter_modules(__path__):
|
||||
if module_info.name.startswith("_") or module_info.name in {"base"}:
|
||||
continue
|
||||
importlib.import_module(f"{__name__}.{module_info.name}")
|
||||
|
||||
|
||||
discover()
|
||||
|
||||
__all__ = [
|
||||
"Strategy",
|
||||
"StrategyContext",
|
||||
"get_strategy",
|
||||
"list_strategies",
|
||||
"param_specs",
|
||||
"register",
|
||||
"strategy_spec",
|
||||
]
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar, Literal, Protocol
|
||||
|
||||
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
|
||||
from app.models import PositionSide
|
||||
from app.schemas import Candle, ParamSpec, StrategyOut
|
||||
|
||||
|
||||
class PositionView(Protocol):
|
||||
"""Minimal position shape a strategy can see (DB row or backtest position)."""
|
||||
|
||||
side: PositionSide
|
||||
qty: float
|
||||
entry_price: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Intent:
|
||||
"""An order request emitted by a strategy. Executed by the engine."""
|
||||
|
||||
action: Literal["enter_long", "enter_short", "close"]
|
||||
size: float | None = None
|
||||
stop_loss: float | None = None
|
||||
take_profit: float | None = None
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class StrategyContext:
|
||||
"""Read-only market/portfolio view plus order intent helpers.
|
||||
|
||||
A fresh context is created for every candle. Strategy code stays synchronous
|
||||
and side-effect free: it calls ``enter_long`` / ``enter_short`` / ``close``
|
||||
and the engine executes the resulting intent after ``on_candle`` returns.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
candles: Sequence[Candle],
|
||||
position: PositionView | None,
|
||||
equity: float,
|
||||
cash: float,
|
||||
) -> None:
|
||||
self._candles = list(candles)
|
||||
self._position = position
|
||||
self._equity = equity
|
||||
self._cash = cash
|
||||
self._intents: list[Intent] = []
|
||||
|
||||
# --- market state -------------------------------------------------
|
||||
@property
|
||||
def candles(self) -> list[Candle]:
|
||||
return self._candles
|
||||
|
||||
@property
|
||||
def price(self) -> float:
|
||||
return self._candles[-1].close
|
||||
|
||||
@property
|
||||
def closes(self) -> list[float]:
|
||||
return [c.close for c in self._candles]
|
||||
|
||||
def high(self, lookback: int) -> float:
|
||||
return max(c.high for c in self._candles[-lookback:])
|
||||
|
||||
def low(self, lookback: int) -> float:
|
||||
return min(c.low for c in self._candles[-lookback:])
|
||||
|
||||
# --- portfolio state ----------------------------------------------
|
||||
@property
|
||||
def position(self) -> PositionView | None:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_flat(self) -> bool:
|
||||
return self._position is None
|
||||
|
||||
@property
|
||||
def side(self) -> PositionSide | None:
|
||||
return self._position.side if self._position else None
|
||||
|
||||
@property
|
||||
def equity(self) -> float:
|
||||
return self._equity
|
||||
|
||||
@property
|
||||
def cash(self) -> float:
|
||||
return self._cash
|
||||
|
||||
# --- indicators ----------------------------------------------------
|
||||
def sma(self, period: int) -> float | None:
|
||||
return sma(self.closes, period)[-1]
|
||||
|
||||
def ema(self, period: int) -> float | None:
|
||||
return ema(self.closes, period)[-1]
|
||||
|
||||
def rsi(self, period: int = 14) -> float | None:
|
||||
return rsi(self.closes, period)[-1]
|
||||
|
||||
def atr(self, period: int = 14) -> float | None:
|
||||
return atr(self._candles, period)[-1]
|
||||
|
||||
def std(self, period: int = 20) -> float | None:
|
||||
return rolling_std(self.closes, period)[-1]
|
||||
|
||||
# --- order intents ------------------------------------------------
|
||||
def enter_long(
|
||||
self,
|
||||
size: float | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
self._intents.append(Intent("enter_long", size, stop_loss, take_profit, reason))
|
||||
|
||||
def enter_short(
|
||||
self,
|
||||
size: float | None = None,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
self._intents.append(Intent("enter_short", size, stop_loss, take_profit, reason))
|
||||
|
||||
def close(self, reason: str = "") -> None:
|
||||
self._intents.append(Intent("close", reason=reason))
|
||||
|
||||
def take_intents(self) -> list[Intent]:
|
||||
intents, self._intents = self._intents, []
|
||||
return intents
|
||||
|
||||
|
||||
class Strategy(ABC):
|
||||
"""Base class for trading strategies.
|
||||
|
||||
Subclass it, set ``key``/``name``/``default_params`` and implement
|
||||
``on_candle``. Save the file under ``app/engine/strategies/`` and it is
|
||||
auto-discovered.
|
||||
"""
|
||||
|
||||
key: ClassVar[str] = ""
|
||||
name: ClassVar[str] = ""
|
||||
description: ClassVar[str] = ""
|
||||
default_params: ClassVar[dict[str, Any]] = {}
|
||||
param_help: ClassVar[dict[str, str]] = {}
|
||||
param_bounds: ClassVar[dict[str, tuple[float, float]]] = {}
|
||||
|
||||
def __init__(self, params: dict[str, Any] | None = None) -> None:
|
||||
unknown = set(params or {}) - set(self.default_params)
|
||||
if unknown:
|
||||
raise ValueError(f"unknown params for {self.key}: {sorted(unknown)}")
|
||||
self.params: dict[str, Any] = {**self.default_params, **(params or {})}
|
||||
|
||||
@abstractmethod
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _infer_type(value: Any) -> Literal["int", "float", "bool", "str"]:
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
if isinstance(value, int):
|
||||
return "int"
|
||||
if isinstance(value, float):
|
||||
return "float"
|
||||
return "str"
|
||||
|
||||
|
||||
def param_specs(strategy_cls: type[Strategy]) -> list[ParamSpec]:
|
||||
specs: list[ParamSpec] = []
|
||||
for name, default in strategy_cls.default_params.items():
|
||||
bounds = strategy_cls.param_bounds.get(name)
|
||||
specs.append(
|
||||
ParamSpec(
|
||||
name=name,
|
||||
type=_infer_type(default),
|
||||
default=default,
|
||||
description=strategy_cls.param_help.get(name, ""),
|
||||
min=bounds[0] if bounds else None,
|
||||
max=bounds[1] if bounds else None,
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
_REGISTRY: dict[str, type[Strategy]] = {}
|
||||
|
||||
|
||||
def register(strategy_cls: type[Strategy]) -> type[Strategy]:
|
||||
"""Class decorator that makes a strategy discoverable by key."""
|
||||
if not strategy_cls.key:
|
||||
raise ValueError(f"{strategy_cls.__name__} must define a non-empty 'key'")
|
||||
_REGISTRY[strategy_cls.key] = strategy_cls
|
||||
return strategy_cls
|
||||
|
||||
|
||||
def get_strategy(key: str) -> type[Strategy]:
|
||||
try:
|
||||
return _REGISTRY[key]
|
||||
except KeyError:
|
||||
raise KeyError(f"unknown strategy '{key}'. available: {sorted(_REGISTRY)}") from None
|
||||
|
||||
|
||||
def list_strategies() -> list[type[Strategy]]:
|
||||
return sorted(_REGISTRY.values(), key=lambda cls: cls.name or cls.key)
|
||||
|
||||
|
||||
def strategy_spec(strategy_cls: type[Strategy]) -> StrategyOut:
|
||||
return StrategyOut(
|
||||
key=strategy_cls.key,
|
||||
name=strategy_cls.name or strategy_cls.key,
|
||||
description=strategy_cls.description,
|
||||
params=param_specs(strategy_cls),
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from app.engine.strategies.base import Strategy, StrategyContext, register
|
||||
from app.models import PositionSide
|
||||
|
||||
|
||||
@register
|
||||
class RsiReversion(Strategy):
|
||||
"""Buy oversold RSI, exit on overbought, with an ATR-based stop."""
|
||||
|
||||
key: ClassVar[str] = "rsi_reversion"
|
||||
name: ClassVar[str] = "RSI Mean Reversion"
|
||||
description: ClassVar[str] = (
|
||||
"Enter long when RSI drops below the oversold level and exit when it rises "
|
||||
"above the overbought level. Places an ATR-based stop loss and take profit."
|
||||
)
|
||||
default_params: ClassVar[dict] = {
|
||||
"period": 14,
|
||||
"oversold": 30.0,
|
||||
"overbought": 70.0,
|
||||
"atr_period": 14,
|
||||
"atr_stop_mult": 2.0,
|
||||
"atr_target_mult": 3.0,
|
||||
"size": 1.0,
|
||||
}
|
||||
param_help: ClassVar[dict] = {
|
||||
"period": "RSI lookback period",
|
||||
"oversold": "RSI level below which to buy",
|
||||
"overbought": "RSI level above which to exit",
|
||||
"atr_period": "ATR lookback for stop sizing",
|
||||
"atr_stop_mult": "Stop distance as a multiple of ATR",
|
||||
"atr_target_mult": "Take-profit distance as a multiple of ATR",
|
||||
"size": "Fraction of equity to allocate per trade",
|
||||
}
|
||||
param_bounds: ClassVar[dict] = {
|
||||
"period": (2, 100),
|
||||
"oversold": (1, 50),
|
||||
"overbought": (50, 99),
|
||||
"atr_stop_mult": (0.5, 10.0),
|
||||
"atr_target_mult": (0.5, 20.0),
|
||||
"size": (0.01, 1.0),
|
||||
}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
period = int(self.params["period"])
|
||||
current = ctx.rsi(period)
|
||||
if current is None:
|
||||
return
|
||||
|
||||
if ctx.is_flat and current < float(self.params["oversold"]):
|
||||
stop = target = None
|
||||
atr_value = ctx.atr(int(self.params["atr_period"]))
|
||||
if atr_value:
|
||||
stop = ctx.price - float(self.params["atr_stop_mult"]) * atr_value
|
||||
target = ctx.price + float(self.params["atr_target_mult"]) * atr_value
|
||||
ctx.enter_long(
|
||||
size=float(self.params["size"]),
|
||||
stop_loss=stop,
|
||||
take_profit=target,
|
||||
reason=f"RSI {current:.1f} below oversold",
|
||||
)
|
||||
elif ctx.side == PositionSide.LONG and current > float(self.params["overbought"]):
|
||||
ctx.close(reason=f"RSI {current:.1f} above overbought")
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from app.engine.indicators import sma
|
||||
from app.engine.strategies.base import Strategy, StrategyContext, register
|
||||
from app.models import PositionSide
|
||||
|
||||
|
||||
@register
|
||||
class SmaCross(Strategy):
|
||||
"""Classic moving-average crossover, long-only."""
|
||||
|
||||
key: ClassVar[str] = "sma_cross"
|
||||
name: ClassVar[str] = "SMA Crossover"
|
||||
description: ClassVar[str] = (
|
||||
"Open a long position when the fast SMA crosses above the slow SMA, "
|
||||
"close it when the fast SMA crosses back below."
|
||||
)
|
||||
default_params: ClassVar[dict] = {"fast": 10, "slow": 30, "size": 1.0}
|
||||
param_help: ClassVar[dict] = {
|
||||
"fast": "Fast SMA period",
|
||||
"slow": "Slow SMA period",
|
||||
"size": "Fraction of equity to allocate per trade",
|
||||
}
|
||||
param_bounds: ClassVar[dict] = {"fast": (2, 200), "slow": (3, 400), "size": (0.01, 1.0)}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
fast_period = int(self.params["fast"])
|
||||
slow_period = int(self.params["slow"])
|
||||
closes = ctx.closes
|
||||
if len(closes) < slow_period + 2:
|
||||
return
|
||||
|
||||
fast = sma(closes, fast_period)
|
||||
slow = sma(closes, slow_period)
|
||||
f_prev, f_now = fast[-2], fast[-1]
|
||||
s_prev, s_now = slow[-2], slow[-1]
|
||||
if None in (f_prev, f_now, s_prev, s_now):
|
||||
return
|
||||
|
||||
crossed_up = f_prev <= s_prev and f_now > s_now
|
||||
crossed_down = f_prev >= s_prev and f_now < s_now
|
||||
|
||||
if ctx.is_flat and crossed_up:
|
||||
ctx.enter_long(size=float(self.params["size"]), reason="fast SMA crossed above slow")
|
||||
elif ctx.side == PositionSide.LONG and crossed_down:
|
||||
ctx.close(reason="fast SMA crossed below slow")
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import router
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import init_db
|
||||
from app.runtime import Runtime
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
runtime: Runtime | None = None,
|
||||
) -> FastAPI:
|
||||
settings = settings or get_settings()
|
||||
runtime = runtime or Runtime(settings)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
init_db()
|
||||
app.state.runtime = runtime
|
||||
yield
|
||||
await runtime.shutdown()
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router)
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"name": settings.app_name, "docs": "/docs"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import Settings
|
||||
from app.market.base import MarketData
|
||||
from app.market.binance import BinanceMarketData
|
||||
from app.market.fake import FakeMarketData
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
__all__ = ["FakeMarketData", "MarketData", "build_market_data"]
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.schemas import Candle
|
||||
|
||||
|
||||
class MarketData(ABC):
|
||||
"""Read-only market data source (candles + last price)."""
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def fetch_price(self, symbol: str) -> float:
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import Candle
|
||||
|
||||
BASE_URL = "https://api.binance.com"
|
||||
|
||||
|
||||
def to_exchange_symbol(symbol: str) -> str:
|
||||
return symbol.replace("/", "").upper()
|
||||
|
||||
|
||||
class BinanceMarketData(MarketData):
|
||||
"""Public Binance 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)
|
||||
|
||||
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
|
||||
response = await self._client.get(
|
||||
"/api/v3/klines",
|
||||
params={
|
||||
"symbol": to_exchange_symbol(symbol),
|
||||
"interval": timeframe,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return [
|
||||
Candle(
|
||||
ts=int(row[0]),
|
||||
open=float(row[1]),
|
||||
high=float(row[2]),
|
||||
low=float(row[3]),
|
||||
close=float(row[4]),
|
||||
volume=float(row[5]),
|
||||
)
|
||||
for row in response.json()
|
||||
]
|
||||
|
||||
async def fetch_price(self, symbol: str) -> float:
|
||||
response = await self._client.get(
|
||||
"/api/v3/ticker/price",
|
||||
params={"symbol": to_exchange_symbol(symbol)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return float(response.json()["price"])
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import Candle
|
||||
|
||||
|
||||
class FakeMarketData(MarketData):
|
||||
"""In-memory market data for tests and offline demos."""
|
||||
|
||||
def __init__(self, candles: Sequence[Candle] | None = None) -> None:
|
||||
self.candles: list[Candle] = list(candles or [])
|
||||
|
||||
@classmethod
|
||||
def from_closes(cls, closes: Sequence[float]) -> FakeMarketData:
|
||||
candles = [
|
||||
Candle(
|
||||
ts=1_700_000_000_000 + i * 60_000,
|
||||
open=close,
|
||||
high=close,
|
||||
low=close,
|
||||
close=close,
|
||||
volume=1.0,
|
||||
)
|
||||
for i, close in enumerate(closes)
|
||||
]
|
||||
return cls(candles)
|
||||
|
||||
@classmethod
|
||||
def sine_wave(cls, n: int = 200, amplitude: float = 100.0, period: int = 20) -> FakeMarketData:
|
||||
base = 1000.0
|
||||
closes = [base + amplitude * math.sin(2 * math.pi * i / period) for i in range(n)]
|
||||
return cls.from_closes(closes)
|
||||
|
||||
def append(self, candle: Candle) -> None:
|
||||
self.candles.append(candle)
|
||||
|
||||
async def fetch_candles(self, symbol: str, timeframe: str, limit: int) -> list[Candle]:
|
||||
return self.candles[-limit:]
|
||||
|
||||
async def fetch_price(self, symbol: str) -> float:
|
||||
if not self.candles:
|
||||
raise ValueError("no candles available")
|
||||
return self.candles[-1].close
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Enum as SAEnum,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class PositionSide(StrEnum):
|
||||
LONG = "long"
|
||||
SHORT = "short"
|
||||
|
||||
|
||||
class PositionStatus(StrEnum):
|
||||
OPEN = "open"
|
||||
CLOSED = "closed"
|
||||
|
||||
|
||||
class OrderSide(StrEnum):
|
||||
BUY = "buy"
|
||||
SELL = "sell"
|
||||
|
||||
|
||||
class StrategyConfig(Base):
|
||||
__tablename__ = "strategy_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
timeframe: Mapped[str] = mapped_column(String(16))
|
||||
params: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Position(Base):
|
||||
__tablename__ = "positions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32), index=True)
|
||||
side: Mapped[PositionSide] = mapped_column(SAEnum(PositionSide))
|
||||
qty: Mapped[float] = mapped_column(Float)
|
||||
entry_price: Mapped[float] = mapped_column(Float)
|
||||
status: Mapped[PositionStatus] = mapped_column(
|
||||
SAEnum(PositionStatus), default=PositionStatus.OPEN, index=True
|
||||
)
|
||||
stop_loss: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
take_profit: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
entry_fee: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
exit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
exit_fee: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
realized_pnl: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
orders: Mapped[list[Order]] = relationship(back_populates="position")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
position_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("positions.id"), nullable=True, index=True
|
||||
)
|
||||
strategy_key: Mapped[str] = mapped_column(String(64), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(32))
|
||||
side: Mapped[OrderSide] = mapped_column(SAEnum(OrderSide))
|
||||
qty: Mapped[float] = mapped_column(Float)
|
||||
price: Mapped[float] = mapped_column(Float)
|
||||
fee: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
reason: Mapped[str] = mapped_column(String(255), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, index=True
|
||||
)
|
||||
|
||||
position: Mapped[Position | None] = relationship(back_populates="orders")
|
||||
|
||||
|
||||
class EquitySnapshot(Base):
|
||||
__tablename__ = "equity_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
equity: Mapped[float] = mapped_column(Float)
|
||||
cash: Mapped[float] = mapped_column(Float)
|
||||
price: Mapped[float] = mapped_column(Float)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, index=True
|
||||
)
|
||||
|
||||
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
level: Mapped[str] = mapped_column(String(16), default="info")
|
||||
message: Mapped[str] = mapped_column(String(512))
|
||||
strategy_key: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, index=True
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from app.backtest import Backtester
|
||||
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.strategies import get_strategy
|
||||
from app.market import build_market_data
|
||||
from app.market.base import MarketData
|
||||
from app.schemas import BacktestRequest, BacktestResult, StatusOut
|
||||
from app.store import Store
|
||||
|
||||
FeedFactory = Callable[[Settings], MarketData]
|
||||
BrokerFactory = Callable[[Settings], Broker]
|
||||
|
||||
|
||||
class Runtime:
|
||||
"""Owns the active engine and fans out live updates to WebSocket clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
store: Store | None = None,
|
||||
*,
|
||||
feed_factory: FeedFactory = build_market_data,
|
||||
broker_factory: BrokerFactory = build_broker,
|
||||
) -> None:
|
||||
self.settings = settings
|
||||
self.store = store or Store()
|
||||
self._feed_factory = feed_factory
|
||||
self._broker_factory = broker_factory
|
||||
self.engine: TradingEngine | None = None
|
||||
self._subscribers: set[asyncio.Queue] = set()
|
||||
|
||||
# --- pub/sub ------------------------------------------------------
|
||||
def subscribe(self) -> asyncio.Queue:
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
self._subscribers.add(queue)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, queue: asyncio.Queue) -> None:
|
||||
self._subscribers.discard(queue)
|
||||
|
||||
def _broadcast(self, payload: dict[str, Any]) -> None:
|
||||
for queue in list(self._subscribers):
|
||||
if queue.full():
|
||||
with suppress(asyncio.QueueEmpty):
|
||||
queue.get_nowait()
|
||||
with suppress(asyncio.QueueFull):
|
||||
queue.put_nowait(payload)
|
||||
|
||||
# --- lifecycle ----------------------------------------------------
|
||||
async def start(
|
||||
self,
|
||||
strategy_key: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
*,
|
||||
symbol: str | None = None,
|
||||
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,
|
||||
)
|
||||
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()
|
||||
|
||||
async def stop(self) -> StatusOut:
|
||||
if self.engine is not None:
|
||||
await self.engine.stop()
|
||||
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
|
||||
strategy_cls = get_strategy(request.strategy_key)
|
||||
strategy = strategy_cls(request.params)
|
||||
|
||||
feed = self._feed_factory(self.settings)
|
||||
try:
|
||||
candles = await feed.fetch_candles(symbol, timeframe, request.limit)
|
||||
finally:
|
||||
await feed.close()
|
||||
|
||||
backtester = Backtester(
|
||||
fee_bps=self.settings.fee_bps,
|
||||
slippage_bps=self.settings.slippage_bps,
|
||||
starting_balance=request.starting_balance or self.settings.starting_balance,
|
||||
)
|
||||
return backtester.run(candles, strategy, symbol, timeframe)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.engine is not None:
|
||||
await self.engine.shutdown()
|
||||
|
||||
def status(self) -> StatusOut:
|
||||
if self.engine is not None:
|
||||
return self.engine.status()
|
||||
return self._idle_status()
|
||||
|
||||
def _idle_status(self) -> StatusOut:
|
||||
return StatusOut(
|
||||
running=False,
|
||||
mode=self.settings.mode,
|
||||
exchange=self.settings.exchange,
|
||||
symbol=self.settings.symbol,
|
||||
timeframe=self.settings.timeframe,
|
||||
strategy_key=None,
|
||||
strategy_name=None,
|
||||
params={},
|
||||
price=None,
|
||||
cash=self.settings.starting_balance,
|
||||
equity=self.settings.starting_balance,
|
||||
starting_balance=self.settings.starting_balance,
|
||||
realized_pnl=0.0,
|
||||
unrealized_pnl=0.0,
|
||||
total_pnl=0.0,
|
||||
open_positions=0,
|
||||
last_tick=None,
|
||||
)
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class Candle(BaseModel):
|
||||
ts: int
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
|
||||
|
||||
class ParamSpec(BaseModel):
|
||||
name: str
|
||||
type: Literal["int", "float", "bool", "str"]
|
||||
default: Any
|
||||
description: str = ""
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
|
||||
|
||||
class StrategyOut(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
description: str
|
||||
params: list[ParamSpec]
|
||||
|
||||
|
||||
class PositionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
strategy_key: str
|
||||
symbol: str
|
||||
side: str
|
||||
qty: float
|
||||
entry_price: float
|
||||
status: str
|
||||
stop_loss: float | None = None
|
||||
take_profit: float | None = None
|
||||
exit_price: float | None = None
|
||||
realized_pnl: float | None = None
|
||||
unrealized_pnl: float | None = None
|
||||
opened_at: datetime
|
||||
closed_at: datetime | None = None
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
position_id: int | None
|
||||
strategy_key: str
|
||||
symbol: str
|
||||
side: str
|
||||
qty: float
|
||||
price: float
|
||||
fee: float
|
||||
reason: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
level: str
|
||||
message: str
|
||||
strategy_key: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EquityPointOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
equity: float
|
||||
cash: float
|
||||
price: float
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class StatusOut(BaseModel):
|
||||
running: bool
|
||||
mode: str
|
||||
exchange: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
strategy_key: str | None
|
||||
strategy_name: str | None
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
price: float | None
|
||||
cash: float
|
||||
equity: float
|
||||
starting_balance: float
|
||||
realized_pnl: float
|
||||
unrealized_pnl: float
|
||||
total_pnl: float
|
||||
open_positions: int
|
||||
last_tick: datetime | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class StartRequest(BaseModel):
|
||||
strategy_key: str
|
||||
symbol: str | None = None
|
||||
timeframe: str | None = None
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
starting_balance: float | None = None
|
||||
|
||||
|
||||
class ManualOrderRequest(BaseModel):
|
||||
side: Literal["buy", "sell"]
|
||||
qty: float | None = None
|
||||
quote_amount: float | None = None
|
||||
reason: str = "manual"
|
||||
|
||||
|
||||
class ClosePositionRequest(BaseModel):
|
||||
reason: str = "manual"
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
strategy_key: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
symbol: str | None = None
|
||||
timeframe: str | None = None
|
||||
limit: int = Field(default=500, ge=20, le=1000)
|
||||
starting_balance: float | None = None
|
||||
|
||||
|
||||
class BacktestTradeOut(BaseModel):
|
||||
side: str
|
||||
entry_ts: int
|
||||
exit_ts: int
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
qty: float
|
||||
pnl: float
|
||||
return_pct: float
|
||||
reason: str
|
||||
|
||||
|
||||
class BacktestPointOut(BaseModel):
|
||||
ts: int
|
||||
equity: float
|
||||
buy_hold: float
|
||||
price: float
|
||||
|
||||
|
||||
class BacktestMetricsOut(BaseModel):
|
||||
initial_equity: float
|
||||
final_equity: float
|
||||
total_return_pct: float
|
||||
buy_hold_return_pct: float
|
||||
max_drawdown_pct: float
|
||||
num_trades: int
|
||||
win_rate_pct: float
|
||||
profit_factor: float | None
|
||||
avg_trade_pct: float
|
||||
best_trade_pct: float
|
||||
worst_trade_pct: float
|
||||
sharpe: float | None
|
||||
|
||||
|
||||
class BacktestResult(BaseModel):
|
||||
strategy_key: str
|
||||
strategy_name: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
starting_balance: float
|
||||
metrics: BacktestMetricsOut
|
||||
equity_curve: list[BacktestPointOut]
|
||||
trades: list[BacktestTradeOut]
|
||||
error: str | None = None
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import (
|
||||
EquitySnapshot,
|
||||
Event,
|
||||
Order,
|
||||
OrderSide,
|
||||
Position,
|
||||
PositionSide,
|
||||
PositionStatus,
|
||||
StrategyConfig,
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
"""Thin persistence layer around the trading tables."""
|
||||
|
||||
def __init__(self, session_factory: sessionmaker = SessionLocal) -> None:
|
||||
self._session_factory: sessionmaker = session_factory
|
||||
|
||||
def create_position(
|
||||
self,
|
||||
*,
|
||||
strategy_key: str,
|
||||
symbol: str,
|
||||
side: PositionSide,
|
||||
qty: float,
|
||||
entry_price: float,
|
||||
entry_fee: float,
|
||||
stop_loss: float | None = None,
|
||||
take_profit: float | None = None,
|
||||
) -> Position:
|
||||
with self._session_factory() as session:
|
||||
position = Position(
|
||||
strategy_key=strategy_key,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=qty,
|
||||
entry_price=entry_price,
|
||||
entry_fee=entry_fee,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
status=PositionStatus.OPEN,
|
||||
)
|
||||
session.add(position)
|
||||
session.commit()
|
||||
return position
|
||||
|
||||
def close_position(
|
||||
self, position: Position, *, exit_price: float, exit_fee: float, realized_pnl: float
|
||||
) -> Position:
|
||||
from app.models import utcnow
|
||||
|
||||
with self._session_factory() as session:
|
||||
merged = session.merge(position)
|
||||
merged.status = PositionStatus.CLOSED
|
||||
merged.exit_price = exit_price
|
||||
merged.exit_fee = exit_fee
|
||||
merged.realized_pnl = realized_pnl
|
||||
merged.closed_at = utcnow()
|
||||
session.commit()
|
||||
return merged
|
||||
|
||||
def abandon_open_positions(self, strategy_key: str, reason: str = "reset") -> int:
|
||||
"""Mark lingering open positions closed at entry price (used on restart)."""
|
||||
from app.models import utcnow
|
||||
|
||||
with self._session_factory() as session:
|
||||
positions = list(
|
||||
session.scalars(
|
||||
select(Position).where(
|
||||
Position.strategy_key == strategy_key,
|
||||
Position.status == PositionStatus.OPEN,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for position in positions:
|
||||
position.status = PositionStatus.CLOSED
|
||||
position.exit_price = position.entry_price
|
||||
position.exit_fee = 0.0
|
||||
position.realized_pnl = -position.entry_fee
|
||||
position.closed_at = utcnow()
|
||||
session.add_all(positions)
|
||||
session.commit()
|
||||
return len(positions)
|
||||
|
||||
def open_position_for(self, strategy_key: str) -> Position | None:
|
||||
with self._session_factory() as session:
|
||||
return session.scalars(
|
||||
select(Position)
|
||||
.where(
|
||||
Position.strategy_key == strategy_key,
|
||||
Position.status == PositionStatus.OPEN,
|
||||
)
|
||||
.order_by(Position.id.desc())
|
||||
).first()
|
||||
|
||||
def record_order(
|
||||
self,
|
||||
*,
|
||||
position_id: int | None,
|
||||
strategy_key: str,
|
||||
symbol: str,
|
||||
side: OrderSide,
|
||||
qty: float,
|
||||
price: float,
|
||||
fee: float,
|
||||
reason: str,
|
||||
) -> Order:
|
||||
with self._session_factory() as session:
|
||||
order = Order(
|
||||
position_id=position_id,
|
||||
strategy_key=strategy_key,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=qty,
|
||||
price=price,
|
||||
fee=fee,
|
||||
reason=reason,
|
||||
)
|
||||
session.add(order)
|
||||
session.commit()
|
||||
return order
|
||||
|
||||
def record_event(self, level: str, message: str, strategy_key: str | None = None) -> Event:
|
||||
with self._session_factory() as session:
|
||||
event = Event(level=level, message=message, strategy_key=strategy_key)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return event
|
||||
|
||||
def record_equity(self, equity: float, cash: float, price: float) -> None:
|
||||
with self._session_factory() as session:
|
||||
session.add(EquitySnapshot(equity=equity, cash=cash, price=price))
|
||||
session.commit()
|
||||
|
||||
def list_positions(self, limit: int = 50) -> list[Position]:
|
||||
with self._session_factory() as session:
|
||||
return list(
|
||||
session.scalars(select(Position).order_by(Position.id.desc()).limit(limit)).all()
|
||||
)
|
||||
|
||||
def list_orders(self, limit: int = 50) -> list[Order]:
|
||||
with self._session_factory() as session:
|
||||
return list(session.scalars(select(Order).order_by(Order.id.desc()).limit(limit)).all())
|
||||
|
||||
def list_events(self, limit: int = 50) -> list[Event]:
|
||||
with self._session_factory() as session:
|
||||
return list(session.scalars(select(Event).order_by(Event.id.desc()).limit(limit)).all())
|
||||
|
||||
def list_equity(self, limit: int = 500) -> list[EquitySnapshot]:
|
||||
with self._session_factory() as session:
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(EquitySnapshot).order_by(EquitySnapshot.id.desc()).limit(limit)
|
||||
).all()
|
||||
)
|
||||
return list(reversed(rows))
|
||||
|
||||
def save_strategy_config(
|
||||
self, 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
|
||||
)
|
||||
session.add(config)
|
||||
session.commit()
|
||||
return config
|
||||
Reference in new issue
Block a user