From 36e05233d0d934d806c5f92a2a5b4730f0f90541 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 9 May 2026 19:11:27 +0200 Subject: [PATCH] feat(backtest): Order/Position/Trade dataclasses with fees Side StrEnum (long/short/flat), frozen dataclasses con calcolo unrealized_pnl per Position e gross/fees/net_pnl per Trade (fees in basis point, default 5bp). 4 test TDD passing, ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/backtest/__init__.py | 0 src/multi_swarm/backtest/orders.py | 59 ++++++++++++++++++++++++++++ tests/unit/test_backtest_orders.py | 38 ++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/multi_swarm/backtest/__init__.py create mode 100644 src/multi_swarm/backtest/orders.py create mode 100644 tests/unit/test_backtest_orders.py diff --git a/src/multi_swarm/backtest/__init__.py b/src/multi_swarm/backtest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multi_swarm/backtest/orders.py b/src/multi_swarm/backtest/orders.py new file mode 100644 index 0000000..de0eb69 --- /dev/null +++ b/src/multi_swarm/backtest/orders.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + + +class Side(StrEnum): + LONG = "long" + SHORT = "short" + FLAT = "flat" + + +@dataclass(frozen=True) +class Order: + ts: datetime + side: Side + size: float + + +@dataclass(frozen=True) +class Position: + side: Side + entry_price: float + size: float + + def unrealized_pnl(self, current_price: float) -> float: + if self.side == Side.LONG: + return (current_price - self.entry_price) * self.size + if self.side == Side.SHORT: + return (self.entry_price - current_price) * self.size + return 0.0 + + +@dataclass(frozen=True) +class Trade: + entry_ts: datetime + exit_ts: datetime + side: Side + size: float + entry_price: float + exit_price: float + fees_bp: float = 5.0 + + @property + def gross_pnl(self) -> float: + if self.side == Side.LONG: + return (self.exit_price - self.entry_price) * self.size + return (self.entry_price - self.exit_price) * self.size + + @property + def fees(self) -> float: + notional_in = self.entry_price * self.size + notional_out = self.exit_price * self.size + return (self.fees_bp / 10000.0) * (notional_in + notional_out) + + @property + def net_pnl(self) -> float: + return self.gross_pnl - self.fees diff --git a/tests/unit/test_backtest_orders.py b/tests/unit/test_backtest_orders.py new file mode 100644 index 0000000..9c0ee6a --- /dev/null +++ b/tests/unit/test_backtest_orders.py @@ -0,0 +1,38 @@ +from datetime import UTC, datetime + +import pytest + +from multi_swarm.backtest.orders import Order, Position, Side, Trade + + +def test_order_validates_side() -> None: + o = Order(ts=datetime(2024, 1, 1, tzinfo=UTC), side=Side.LONG, size=1.0) + assert o.side == Side.LONG + + +def test_position_pnl_long() -> None: + pos = Position(side=Side.LONG, entry_price=100.0, size=2.0) + assert pos.unrealized_pnl(110.0) == pytest.approx(20.0) + assert pos.unrealized_pnl(90.0) == pytest.approx(-20.0) + + +def test_position_pnl_short() -> None: + pos = Position(side=Side.SHORT, entry_price=100.0, size=2.0) + assert pos.unrealized_pnl(110.0) == pytest.approx(-20.0) + assert pos.unrealized_pnl(90.0) == pytest.approx(20.0) + + +def test_trade_realized_pnl_with_fees() -> None: + t = Trade( + entry_ts=datetime(2024, 1, 1, tzinfo=UTC), + exit_ts=datetime(2024, 1, 2, tzinfo=UTC), + side=Side.LONG, + size=1.0, + entry_price=100.0, + exit_price=110.0, + fees_bp=5.0, + ) + # gross 10, fees = 5bp * (100+110) = 0.0005 * 210 = 0.105 + assert t.gross_pnl == pytest.approx(10.0) + assert t.fees == pytest.approx(0.105) + assert t.net_pnl == pytest.approx(9.895)