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.
111 lines
3.5 KiB
Python
111 lines
3.5 KiB
Python
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
|