Trailing stops: strategies can pass trailing_stop_pct to enter_long/enter_short. advance_trailing_stop in app/engine/portfolio.py ratchets the stop with the price (up for longs, down for shorts) and never loosens; the engine persists the ratcheted level and the backtester applies the same logic. Position gains a trailing_stop_pct column (additive migration). momentum exposes it as a param. Parameter sweeps: app/backtest/sweep.py runs a cartesian grid through the same backtester and ranks by return minus max drawdown. POST /api/sweep plus a dashboard Parameter sweep panel.
88 lines
3.6 KiB
Python
88 lines
3.6 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.engine.portfolio import (
|
|
account_equity,
|
|
advance_trailing_stop,
|
|
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
|
|
|
|
|
|
def test_trailing_stop_long_ratchets_up_only() -> None:
|
|
# First update seeds the stop 2% below the high.
|
|
stop = advance_trailing_stop(PositionSide.LONG, None, 0.02, high=100, low=95)
|
|
assert stop == pytest.approx(98.0)
|
|
# A higher high raises it.
|
|
stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=110, low=100)
|
|
assert stop == pytest.approx(107.8)
|
|
# A pullback never loosens it.
|
|
stop = advance_trailing_stop(PositionSide.LONG, stop, 0.02, high=105, low=100)
|
|
assert stop == pytest.approx(107.8)
|
|
|
|
|
|
def test_trailing_stop_short_ratchets_down_only() -> None:
|
|
stop = advance_trailing_stop(PositionSide.SHORT, None, 0.02, high=100, low=95)
|
|
assert stop == pytest.approx(96.9)
|
|
stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=90)
|
|
assert stop == pytest.approx(91.8)
|
|
# A bounce never loosens the short's stop.
|
|
stop = advance_trailing_stop(PositionSide.SHORT, stop, 0.02, high=95, low=92)
|
|
assert stop == pytest.approx(91.8)
|
|
|
|
|
|
def test_trailing_stop_disabled_is_noop() -> None:
|
|
assert advance_trailing_stop(PositionSide.LONG, 90.0, 0.0, high=100, low=80) == 90.0
|
|
assert advance_trailing_stop(PositionSide.LONG, None, 0.0, high=100, low=80) is None
|