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,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