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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user