From c467261f23b3cf970b7202df700522c28cac66a8 Mon Sep 17 00:00:00 2001 From: Alex Kwiatkowski Date: Thu, 25 Jun 2026 23:54:44 -0700 Subject: [PATCH] fix: preserve gateway trade commissions & normalize trade response fields --- src/project_x_py/client/trading.py | 11 +++++++- src/project_x_py/models.py | 18 ++---------- src/project_x_py/types/api_responses.py | 3 +- tests/client/test_trading_legacy.py | 37 +++++++++++++++++++++++++ tests/types/test_api_responses.py | 2 +- tests/types/test_models.py | 17 ++++++++++++ 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/project_x_py/client/trading.py b/src/project_x_py/client/trading.py index 9761784..127d928 100644 --- a/src/project_x_py/client/trading.py +++ b/src/project_x_py/client/trading.py @@ -77,6 +77,15 @@ async def main(): logger = logging.getLogger(__name__) +_TRADE_FIELDS = frozenset(Trade.__slots__) + + +def _trade_from_response(data: dict[str, Any]) -> Trade: + values = dict(data) + if "fees" not in values and "commissions" in values: + values["fees"] = values["commissions"] + return Trade(**{field: values[field] for field in _TRADE_FIELDS if field in values}) + class TradingMixin: """Mixin class providing trading functionality.""" @@ -273,4 +282,4 @@ async def search_trades( if not response or not isinstance(response, list): return [] - return [Trade(**trade) for trade in response] + return [_trade_from_response(trade) for trade in response] diff --git a/src/project_x_py/models.py b/src/project_x_py/models.py index 05c9897..267a70c 100644 --- a/src/project_x_py/models.py +++ b/src/project_x_py/models.py @@ -455,7 +455,7 @@ def unrealized_pnl(self, current_price: float, tick_value: float = 1.0) -> float return 0.0 -@dataclass +@dataclass(slots=True) class Trade: """ Represents an executed trade with P&L information. @@ -468,6 +468,7 @@ class Trade: price (float): Execution price profitAndLoss (Optional[float]): Realized P&L (None for half-turn trades) fees (float): Trading fees/commissions + commissions (Optional[float]): Gateway commission amount, if provided side (int): Trade side: 0=Buy, 1=Sell size (int): Number of contracts traded voided (bool): Whether the trade was voided/cancelled @@ -483,20 +484,6 @@ class Trade: >>> print(f"{side_str} {trade.size} @ ${trade.price} - P&L: {pnl_str}") """ - __slots__ = ( - "accountId", - "contractId", - "creationTimestamp", - "fees", - "id", - "orderId", - "price", - "profitAndLoss", - "side", - "size", - "voided", - ) - id: int accountId: int contractId: str @@ -508,6 +495,7 @@ class Trade: size: int voided: bool orderId: int + commissions: float | None = None @dataclass diff --git a/src/project_x_py/types/api_responses.py b/src/project_x_py/types/api_responses.py index 0c4d5ec..ae14e0a 100644 --- a/src/project_x_py/types/api_responses.py +++ b/src/project_x_py/types/api_responses.py @@ -144,7 +144,8 @@ class TradeResponse(TypedDict): creationTimestamp: str price: float profitAndLoss: NotRequired[float] # None for half-turn trades - fees: float + fees: NotRequired[float] + commissions: NotRequired[float] side: int # 0=Buy, 1=Sell size: int voided: bool diff --git a/tests/client/test_trading_legacy.py b/tests/client/test_trading_legacy.py index a9815c6..3a84008 100644 --- a/tests/client/test_trading_legacy.py +++ b/tests/client/test_trading_legacy.py @@ -393,6 +393,43 @@ async def test_search_trades_with_contract_filter(self, trading_client): assert call_args[1]["params"]["contractId"] == "MNQ" assert call_args[1]["params"]["limit"] == 50 + @pytest.mark.asyncio + async def test_search_trades_preserves_gateway_commissions_field( + self, trading_client + ): + """Test trade search keeps Gateway commissions while setting fees.""" + trading_client.account_info = Account( + id=12345, + name="Test Account", + balance=10000.0, + canTrade=True, + isVisible=True, + simulated=False, + ) + + trading_client._make_request.return_value = [ + { + "id": 1, + "accountId": 12345, + "contractId": "MNQ", + "creationTimestamp": datetime.datetime.now(pytz.UTC).isoformat(), + "price": 15000.0, + "profitAndLoss": 75.0, + "commissions": 2.25, + "side": 0, + "size": 3, + "voided": False, + "orderId": 102, + "extraField": "ignored", + } + ] + + trades = await trading_client.search_trades(contract_id="MNQ") + + assert len(trades) == 1 + assert trades[0].fees == 2.25 + assert trades[0].commissions == 2.25 + @pytest.mark.asyncio async def test_search_trades_custom_account_id(self, trading_client): """Test trade search with custom account ID.""" diff --git a/tests/types/test_api_responses.py b/tests/types/test_api_responses.py index 12a6231..6baadd2 100644 --- a/tests/types/test_api_responses.py +++ b/tests/types/test_api_responses.py @@ -129,7 +129,7 @@ def test_trade_response_structure(self): assert "side" in hints assert hints["side"] is int assert "fees" in hints - assert hints["fees"] is float + assert "commissions" in hints # Optional P&L (None for half-turn trades) assert "profitAndLoss" in hints diff --git a/tests/types/test_models.py b/tests/types/test_models.py index 8457fd3..4eac5eb 100644 --- a/tests/types/test_models.py +++ b/tests/types/test_models.py @@ -183,6 +183,23 @@ def test_trade_slots_and_attributes(self): # Access attributes assert t.price == pytest.approx(5000.0) assert t.profitAndLoss is None # half-turn trade allowed + assert t.commissions is None + + t_with_commissions = Trade( + id=8, + accountId=10, + contractId="CON.F.US.MNQ.H25", + creationTimestamp="2024-01-01T00:01:00Z", + price=5001.0, + profitAndLoss=10.0, + fees=2.5, + side=1, + size=1, + voided=False, + orderId=124, + commissions=2.5, + ) + assert t_with_commissions.commissions == pytest.approx(2.5) # __slots__ should prevent setting unknown attributes with pytest.raises(AttributeError):