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
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
data/
|
||||
*.db
|
||||
*.sqlite3
|
||||
.env
|
||||
.git/
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
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
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
addopts = -q
|
||||
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.3
|
||||
pytest-asyncio>=0.25
|
||||
anyio>=4.7
|
||||
ruff>=0.8
|
||||
@@ -0,0 +1,11 @@
|
||||
# Core web stack
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.34
|
||||
pydantic>=2.9
|
||||
pydantic-settings>=2.7
|
||||
# Persistence (sync SQLAlchemy; SQLite by default, Postgres optional)
|
||||
SQLAlchemy>=2.0.36
|
||||
# HTTP client used for market data + live exchange REST calls
|
||||
httpx>=0.28
|
||||
# WebSocket protocol support for uvicorn / starlette
|
||||
websockets>=14.0
|
||||
@@ -0,0 +1,9 @@
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "I", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["B008"]
|
||||
|
||||
[format]
|
||||
quote-style = "double"
|
||||
@@ -0,0 +1,63 @@
|
||||
# ruff: noqa: E402, I001
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./data/test_trader.db"
|
||||
os.environ["FEE_BPS"] = "0"
|
||||
os.environ["SLIPPAGE_BPS"] = "0"
|
||||
os.environ["POLL_INTERVAL"] = "3600"
|
||||
os.environ["STARTING_BALANCE"] = "10000"
|
||||
|
||||
from collections.abc import Iterator # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from app.broker.paper import PaperBroker # noqa: E402
|
||||
from app.config import Settings # noqa: E402
|
||||
from app.market.fake import FakeMarketData # noqa: E402
|
||||
from app.models import Base # noqa: E402
|
||||
from app.db import engine as db_engine # noqa: E402
|
||||
from app.runtime import Runtime # noqa: E402
|
||||
from app.store import Store # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings(
|
||||
mode="paper",
|
||||
symbol="BTC/USDT",
|
||||
timeframe="1m",
|
||||
poll_interval=3600,
|
||||
starting_balance=10_000,
|
||||
fee_bps=0,
|
||||
slippage_bps=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_db() -> Iterator[None]:
|
||||
Base.metadata.drop_all(bind=db_engine)
|
||||
Base.metadata.create_all(bind=db_engine)
|
||||
yield
|
||||
Base.metadata.drop_all(bind=db_engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> Store:
|
||||
return Store()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def market() -> FakeMarketData:
|
||||
return FakeMarketData.from_closes([100.0, 101.0, 102.0])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime(settings: Settings, store: Store, market: FakeMarketData) -> Runtime:
|
||||
return Runtime(
|
||||
settings,
|
||||
store,
|
||||
feed_factory=lambda _settings: market,
|
||||
broker_factory=lambda _settings: PaperBroker(fee_bps=0, slippage_bps=0),
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.market.fake import FakeMarketData
|
||||
from app.runtime import Runtime
|
||||
|
||||
|
||||
def test_health_and_strategies(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/health").json() == {"status": "ok"}
|
||||
keys = {s["key"] for s in client.get("/api/strategies").json()}
|
||||
assert {"sma_cross", "rsi_reversion"} <= keys
|
||||
|
||||
|
||||
def test_start_status_stop_flow(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
status = client.get("/api/status").json()
|
||||
assert status["running"] is False
|
||||
|
||||
started = client.post(
|
||||
"/api/bot/start",
|
||||
json={"strategy_key": "sma_cross", "params": {"fast": 2, "slow": 4}},
|
||||
)
|
||||
assert started.status_code == 200
|
||||
assert started.json()["running"] is True
|
||||
|
||||
assert client.get("/api/positions").status_code == 200
|
||||
assert client.get("/api/orders").status_code == 200
|
||||
assert client.get("/api/events").status_code == 200
|
||||
|
||||
stopped = client.post("/api/bot/stop").json()
|
||||
assert stopped["running"] is False
|
||||
|
||||
|
||||
def test_unknown_strategy_returns_404(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/bot/start", json={"strategy_key": "does_not_exist"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_backtest_endpoint(runtime: Runtime, market: FakeMarketData) -> None:
|
||||
market.candles = FakeMarketData.sine_wave(200).candles
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/backtest",
|
||||
json={"strategy_key": "sma_cross", "params": {"fast": 5, "slow": 20}, "limit": 200},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["strategy_key"] == "sma_cross"
|
||||
assert data["error"] is None
|
||||
assert len(data["equity_curve"]) == 200
|
||||
assert "total_return_pct" in data["metrics"]
|
||||
assert data["metrics"]["num_trades"] >= 1
|
||||
|
||||
|
||||
def test_backtest_unknown_strategy_404(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/backtest", json={"strategy_key": "nope"})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_backtest_limit_validation(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client:
|
||||
assert (
|
||||
client.post("/api/backtest", json={"strategy_key": "sma_cross", "limit": 5}).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
def test_websocket_sends_initial_status(runtime: Runtime) -> None:
|
||||
app = create_app(runtime.settings, runtime)
|
||||
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
|
||||
message = websocket.receive_json()
|
||||
assert message["type"] == "status"
|
||||
assert message["data"]["running"] is False
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from app.backtest import Backtester
|
||||
from app.backtest.metrics import max_drawdown_pct, sharpe_ratio
|
||||
from app.engine.strategies.base import Strategy, StrategyContext
|
||||
from app.schemas import BacktestPointOut, BacktestTradeOut, Candle
|
||||
|
||||
|
||||
class HoldStrategy(Strategy):
|
||||
"""Enter long on the first candle and stay in (closed at end of data)."""
|
||||
|
||||
key: ClassVar[str] = "test_hold"
|
||||
name: ClassVar[str] = "Test Hold"
|
||||
default_params: ClassVar[dict] = {}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
if ctx.is_flat:
|
||||
ctx.enter_long(size=1.0)
|
||||
|
||||
|
||||
class NeverStrategy(Strategy):
|
||||
key: ClassVar[str] = "test_never"
|
||||
name: ClassVar[str] = "Test Never"
|
||||
default_params: ClassVar[dict] = {}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class StopStrategy(Strategy):
|
||||
key: ClassVar[str] = "test_stop"
|
||||
name: ClassVar[str] = "Test Stop"
|
||||
default_params: ClassVar[dict] = {}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
if ctx.is_flat and not getattr(self, "_entered", False):
|
||||
self._entered = True
|
||||
ctx.enter_long(size=1.0, stop_loss=ctx.price * 0.9)
|
||||
|
||||
|
||||
def candles_from_closes(closes: list[float]) -> list[Candle]:
|
||||
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
|
||||
|
||||
|
||||
def test_hold_backtest_math() -> None:
|
||||
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
|
||||
result = backtester.run(candles_from_closes([100, 110, 120]), HoldStrategy(), "BTC/USDT", "1h")
|
||||
|
||||
assert result.error is None
|
||||
assert result.metrics.num_trades == 1
|
||||
# 99 units bought at 100 (cash capped at 99%), closed at 120.
|
||||
assert result.metrics.final_equity == pytest.approx(11_980.0)
|
||||
assert result.trades[0].pnl == pytest.approx(1_980.0)
|
||||
assert result.trades[0].return_pct == pytest.approx(20.0)
|
||||
assert result.trades[0].reason == "end of data"
|
||||
assert len(result.equity_curve) == 3
|
||||
|
||||
|
||||
def test_no_trades_leaves_balance_untouched() -> None:
|
||||
backtester = Backtester(fee_bps=10, slippage_bps=5, starting_balance=5_000)
|
||||
result = backtester.run(
|
||||
candles_from_closes([100, 101, 102, 103]), NeverStrategy(), "BTC/USDT", "1m"
|
||||
)
|
||||
assert result.metrics.num_trades == 0
|
||||
assert result.metrics.final_equity == pytest.approx(5_000.0)
|
||||
assert result.metrics.total_return_pct == pytest.approx(0.0)
|
||||
assert result.metrics.max_drawdown_pct == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_stop_loss_is_honoured() -> None:
|
||||
backtester = Backtester(fee_bps=0, slippage_bps=0, starting_balance=10_000)
|
||||
candles = candles_from_closes([100, 100, 80])
|
||||
candles[2] = Candle(ts=2, open=100, high=100, low=80, close=80, volume=1.0)
|
||||
result = backtester.run(candles, StopStrategy(), "BTC/USDT", "1h")
|
||||
|
||||
assert result.metrics.num_trades == 1
|
||||
assert "stop loss" in result.trades[0].reason
|
||||
assert result.metrics.final_equity < 10_000
|
||||
|
||||
|
||||
def test_strategy_error_is_surfaced() -> None:
|
||||
class Boom(Strategy):
|
||||
key = "test_boom"
|
||||
name = "Boom"
|
||||
default_params: ClassVar[dict] = {}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
result = Backtester().run(candles_from_closes([1, 2, 3]), Boom(), "BTC/USDT", "1m")
|
||||
assert result.error is not None and "kaboom" in result.error
|
||||
|
||||
|
||||
def test_max_drawdown() -> None:
|
||||
assert max_drawdown_pct([100, 120, 90, 130]) == pytest.approx(25.0)
|
||||
assert max_drawdown_pct([100, 100, 100]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_sharpe_none_for_unknown_timeframe() -> None:
|
||||
assert sharpe_ratio([100, 101, 102, 103], "nonsense") is None
|
||||
|
||||
|
||||
def _points(equities: list[float]) -> list[BacktestPointOut]:
|
||||
return [BacktestPointOut(ts=i, equity=e, buy_hold=e, price=e) for i, e in enumerate(equities)]
|
||||
|
||||
|
||||
def test_metrics_profit_factor_and_win_rate() -> None:
|
||||
from app.backtest.metrics import compute_metrics
|
||||
|
||||
trades = [
|
||||
BacktestTradeOut(
|
||||
side="long",
|
||||
entry_ts=0,
|
||||
exit_ts=1,
|
||||
entry_price=100,
|
||||
exit_price=110,
|
||||
qty=1,
|
||||
pnl=10,
|
||||
return_pct=10,
|
||||
reason="x",
|
||||
),
|
||||
BacktestTradeOut(
|
||||
side="long",
|
||||
entry_ts=2,
|
||||
exit_ts=3,
|
||||
entry_price=100,
|
||||
exit_price=95,
|
||||
qty=1,
|
||||
pnl=-5,
|
||||
return_pct=-5,
|
||||
reason="x",
|
||||
),
|
||||
]
|
||||
metrics = compute_metrics(_points([10_000, 10_010, 10_005]), trades, 10_000, "1h")
|
||||
assert metrics.win_rate_pct == pytest.approx(50.0)
|
||||
assert metrics.profit_factor == pytest.approx(2.0)
|
||||
assert metrics.best_trade_pct == pytest.approx(10.0)
|
||||
assert metrics.worst_trade_pct == pytest.approx(-5.0)
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
|
||||
from app.broker.paper import PaperBroker
|
||||
from app.config import Settings
|
||||
from app.engine.engine import TradingEngine
|
||||
from app.engine.strategies.base import Strategy, StrategyContext
|
||||
from app.market.fake import FakeMarketData
|
||||
from app.models import OrderSide, PositionSide
|
||||
from app.schemas import Candle
|
||||
from app.store import Store
|
||||
|
||||
|
||||
class ToggleStrategy(Strategy):
|
||||
key: ClassVar[str] = "test_toggle"
|
||||
name: ClassVar[str] = "Toggle"
|
||||
default_params: ClassVar[dict] = {}
|
||||
|
||||
def on_candle(self, ctx: StrategyContext) -> None:
|
||||
if ctx.is_flat:
|
||||
ctx.enter_long(size=0.5)
|
||||
else:
|
||||
ctx.close()
|
||||
|
||||
|
||||
def build_engine(settings: Settings, store: Store, market: FakeMarketData) -> TradingEngine:
|
||||
return TradingEngine(
|
||||
feed=market,
|
||||
broker=PaperBroker(fee_bps=0, slippage_bps=0),
|
||||
store=store,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
async def test_manual_open_and_close_pnl(settings: Settings, store: Store) -> None:
|
||||
market = FakeMarketData.from_closes([100.0])
|
||||
engine = build_engine(settings, store, market)
|
||||
engine.symbol = "BTC/USDT"
|
||||
engine.last_price = 100.0
|
||||
|
||||
await engine.manual_order(OrderSide.BUY, qty=1.0, quote_amount=None, reason="test")
|
||||
assert engine.position is not None
|
||||
assert engine.cash == pytest.approx(9_900.0)
|
||||
assert engine.equity == pytest.approx(10_000.0)
|
||||
|
||||
engine.last_price = 110.0
|
||||
assert engine.equity == pytest.approx(10_010.0)
|
||||
assert engine.unrealized_pnl == pytest.approx(10.0)
|
||||
|
||||
await engine.close_current_position()
|
||||
assert engine.position is None
|
||||
assert engine.realized_pnl == pytest.approx(10.0)
|
||||
assert engine.cash == pytest.approx(10_010.0)
|
||||
|
||||
|
||||
async def test_stop_loss_exits_at_level(settings: Settings, store: Store) -> None:
|
||||
market = FakeMarketData.from_closes([100.0])
|
||||
engine = build_engine(settings, store, market)
|
||||
engine.symbol = "BTC/USDT"
|
||||
engine.last_price = 100.0
|
||||
|
||||
await engine._open_position(
|
||||
PositionSide.LONG,
|
||||
size=0.0,
|
||||
stop_loss=95.0,
|
||||
take_profit=120.0,
|
||||
reason="entry",
|
||||
qty_override=1.0,
|
||||
)
|
||||
await engine._check_risk(Candle(ts=0, open=100, high=101, low=94, close=96, volume=1))
|
||||
assert engine.position is None
|
||||
assert engine.realized_pnl == pytest.approx(-5.0)
|
||||
|
||||
|
||||
async def test_take_profit_exits_at_level(settings: Settings, store: Store) -> None:
|
||||
market = FakeMarketData.from_closes([100.0])
|
||||
engine = build_engine(settings, store, market)
|
||||
engine.symbol = "BTC/USDT"
|
||||
engine.last_price = 100.0
|
||||
|
||||
await engine._open_position(
|
||||
PositionSide.LONG,
|
||||
size=0.0,
|
||||
stop_loss=95.0,
|
||||
take_profit=110.0,
|
||||
reason="entry",
|
||||
qty_override=1.0,
|
||||
)
|
||||
await engine._check_risk(Candle(ts=0, open=100, high=111, low=100, close=109, volume=1))
|
||||
assert engine.position is None
|
||||
assert engine.realized_pnl == pytest.approx(10.0)
|
||||
|
||||
|
||||
async def test_tick_runs_strategy_and_opens_position(settings: Settings, store: Store) -> None:
|
||||
market = FakeMarketData.from_closes([100.0, 100.0])
|
||||
engine = build_engine(settings, store, market)
|
||||
engine.strategy = ToggleStrategy()
|
||||
engine.strategy_key = "test_toggle"
|
||||
engine.symbol = "BTC/USDT"
|
||||
|
||||
await engine.tick()
|
||||
assert engine.position is not None
|
||||
assert engine.position.side == PositionSide.LONG
|
||||
|
||||
market.append(Candle(ts=99, open=110, high=110, low=110, close=110, volume=1))
|
||||
await engine.tick()
|
||||
assert engine.position is None
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.indicators import atr, ema, rolling_std, rsi, sma
|
||||
from app.schemas import Candle
|
||||
|
||||
|
||||
def candle(high: float, low: float, close: float, open_: float | None = None) -> Candle:
|
||||
return Candle(
|
||||
ts=0,
|
||||
open=open_ if open_ is not None else close,
|
||||
high=high,
|
||||
low=low,
|
||||
close=close,
|
||||
volume=1,
|
||||
)
|
||||
|
||||
|
||||
def test_sma() -> None:
|
||||
assert sma([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
def test_ema() -> None:
|
||||
assert ema([1, 2, 3, 4, 5], 3) == [None, None, 2.0, 3.0, 4.0]
|
||||
|
||||
|
||||
def test_rsi_monotonic_is_100() -> None:
|
||||
values = [float(i) for i in range(1, 20)]
|
||||
assert rsi(values, 14)[-1] == pytest.approx(100.0)
|
||||
|
||||
|
||||
def test_atr_flat_market_is_zero() -> None:
|
||||
candles = [candle(100, 100, 100) for _ in range(20)]
|
||||
assert atr(candles, 5)[-1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_rolling_std() -> None:
|
||||
result = rolling_std([1, 1, 1, 1], 2)
|
||||
assert result[-1] == pytest.approx(0.0)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.portfolio import (
|
||||
account_equity,
|
||||
apply_close,
|
||||
apply_open,
|
||||
order_qty,
|
||||
realized_pnl,
|
||||
risk_exit,
|
||||
unrealized_pnl,
|
||||
)
|
||||
from app.models import PositionSide
|
||||
|
||||
|
||||
def test_apply_open_long_and_short() -> None:
|
||||
assert apply_open(1000.0, PositionSide.LONG, 1.0, 100.0, 1.0) == pytest.approx(899.0)
|
||||
assert apply_open(1000.0, PositionSide.SHORT, 1.0, 100.0, 1.0) == pytest.approx(1099.0)
|
||||
|
||||
|
||||
def test_apply_close_long_and_short() -> None:
|
||||
assert apply_close(100.0, PositionSide.LONG, 1.0, 120.0, 1.0) == pytest.approx(219.0)
|
||||
assert apply_close(2100.0, PositionSide.SHORT, 1.0, 90.0, 1.0) == pytest.approx(2009.0)
|
||||
|
||||
|
||||
def test_realized_pnl_includes_fees() -> None:
|
||||
assert realized_pnl(PositionSide.LONG, 100, 110, 2, 1, 1) == pytest.approx(18.0)
|
||||
assert realized_pnl(PositionSide.SHORT, 100, 90, 2, 1, 1) == pytest.approx(18.0)
|
||||
assert realized_pnl(PositionSide.LONG, 100, 90, 2, 1, 1) == pytest.approx(-22.0)
|
||||
|
||||
|
||||
def test_unrealized_pnl() -> None:
|
||||
assert unrealized_pnl(PositionSide.LONG, 100, 110, 3) == pytest.approx(30.0)
|
||||
assert unrealized_pnl(PositionSide.SHORT, 100, 110, 3) == pytest.approx(-30.0)
|
||||
|
||||
|
||||
def test_account_equity_long_and_short() -> None:
|
||||
assert account_equity(100, PositionSide.LONG, 2, 150) == pytest.approx(400.0)
|
||||
assert account_equity(300, PositionSide.SHORT, 2, 80) == pytest.approx(140.0)
|
||||
|
||||
|
||||
def test_order_qty_caps_longs_by_cash_and_scales_with_size() -> None:
|
||||
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 100) == pytest.approx(99.0)
|
||||
assert order_qty(10_000, 0.5, PositionSide.SHORT, 10_000, 100) == pytest.approx(50.0)
|
||||
assert order_qty(10_000, 1.0, PositionSide.SHORT, 0, 100) == pytest.approx(100.0)
|
||||
assert order_qty(10_000, 1.0, PositionSide.LONG, 10_000, 0) == 0.0
|
||||
|
||||
|
||||
def test_risk_exit_long() -> None:
|
||||
assert risk_exit(PositionSide.LONG, 95, 120, high=101, low=94) == ("stop loss", 95)
|
||||
assert risk_exit(PositionSide.LONG, 95, 120, high=121, low=100) == ("take profit", 120)
|
||||
assert risk_exit(PositionSide.LONG, 95, 120, high=110, low=100) is None
|
||||
|
||||
|
||||
def test_risk_exit_short() -> None:
|
||||
assert risk_exit(PositionSide.SHORT, 110, 90, high=111, low=100) == ("stop loss", 110)
|
||||
assert risk_exit(PositionSide.SHORT, 110, 90, high=100, low=89) == ("take profit", 90)
|
||||
assert risk_exit(PositionSide.SHORT, None, None, high=100, low=95) is None
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.strategies import get_strategy, list_strategies, strategy_spec
|
||||
from app.engine.strategies.base import StrategyContext
|
||||
from app.models import Position, PositionSide
|
||||
from app.schemas import Candle
|
||||
|
||||
|
||||
def make_candles(closes: list[float]) -> list[Candle]:
|
||||
return [Candle(ts=i, open=c, high=c, low=c, close=c, volume=1.0) for i, c in enumerate(closes)]
|
||||
|
||||
|
||||
def test_examples_are_registered() -> None:
|
||||
keys = {cls.key for cls in list_strategies()}
|
||||
assert {"sma_cross", "rsi_reversion"} <= keys
|
||||
|
||||
|
||||
def test_param_specs_expose_defaults() -> None:
|
||||
spec = strategy_spec(get_strategy("sma_cross"))
|
||||
names = {param.name for param in spec.params}
|
||||
assert {"fast", "slow", "size"} == names
|
||||
fast = next(param for param in spec.params if param.name == "fast")
|
||||
assert fast.default == 10
|
||||
assert fast.type == "int"
|
||||
|
||||
|
||||
def test_unknown_params_rejected() -> None:
|
||||
cls = get_strategy("sma_cross")
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
cls({"nope": 1})
|
||||
|
||||
|
||||
def test_sma_cross_emits_enter_long() -> None:
|
||||
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
|
||||
ctx = StrategyContext(
|
||||
candles=make_candles([10, 10, 10, 10, 10, 11]),
|
||||
position=None,
|
||||
equity=1000,
|
||||
cash=1000,
|
||||
)
|
||||
strategy.on_candle(ctx)
|
||||
intents = ctx.take_intents()
|
||||
assert len(intents) == 1
|
||||
assert intents[0].action == "enter_long"
|
||||
assert intents[0].size == 0.5
|
||||
|
||||
|
||||
def test_sma_cross_emits_close_on_cross_down() -> None:
|
||||
strategy = get_strategy("sma_cross")({"fast": 2, "slow": 4, "size": 0.5})
|
||||
position = Position(
|
||||
strategy_key="sma_cross",
|
||||
symbol="BTC/USDT",
|
||||
side=PositionSide.LONG,
|
||||
qty=1.0,
|
||||
entry_price=10.0,
|
||||
)
|
||||
ctx = StrategyContext(
|
||||
candles=make_candles([10, 10, 10, 10, 10, 11, 8]),
|
||||
position=position,
|
||||
equity=1000,
|
||||
cash=1000,
|
||||
)
|
||||
strategy.on_candle(ctx)
|
||||
intents = ctx.take_intents()
|
||||
assert len(intents) == 1
|
||||
assert intents[0].action == "close"
|
||||
Reference in new issue
Block a user