Crypto trading bot: engine, brokers, backtester, dashboard, deploy
CI/CD / backend (push) Successful in 5m18s
CI/CD / frontend (push) Successful in 5m2s
CI/CD / build (push) Successful in 34s
CI/CD / cleanup (push) Successful in 6s
CI/CD / deploy (push) Successful in 22s

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:
matitos committed 2026-09-18 17:02:33 +00:00
1 parent 98028248c2
commit 9c3bee87ac
73 files changed
+9759 -1

No files matched your search

+35
View File
@@ -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",
]
+217
View File
@@ -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")