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,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)
|
||||
Reference in new issue
Block a user