Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/project_x_py/client/trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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]
18 changes: 3 additions & 15 deletions src/project_x_py/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -508,6 +495,7 @@ class Trade:
size: int
voided: bool
orderId: int
commissions: float | None = None


@dataclass
Expand Down
3 changes: 2 additions & 1 deletion src/project_x_py/types/api_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions tests/client/test_trading_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
2 changes: 1 addition & 1 deletion tests/types/test_api_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/types/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down