Skip to content
Merged
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
9 changes: 8 additions & 1 deletion src/quant_platform_kit/ibkr/order_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class OutcomeFill:
execution_id: str
quantity: float
price: float
occurred_at: str | None = None
currency: str | None = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -201,7 +203,12 @@ def resolve_order_outcome(
at = _time(execution.time)
if at.replace(microsecond=0) < since.replace(microsecond=0):
return unknown
fill = OutcomeFill(str(execution.execId), _number(execution.shares), _number(execution.price))
currency = getattr(item.contract, 'currency', None)
if not (isinstance(currency, str) and len(currency) == 3
and currency.isascii() and currency.isalpha() and currency.isupper()):
currency = None
fill = OutcomeFill(str(execution.execId), _number(execution.shares), _number(execution.price),
at.isoformat().replace('+00:00', 'Z'), currency)
if not fill.execution_id or fill.quantity <= 0 or fill.price <= 0:
return unknown
commission = None
Expand Down
59 changes: 58 additions & 1 deletion tests/test_ibkr_order_outcome.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import pytest

from quant_platform_kit.ibkr.order_outcome import resolve_order_outcome, OrderOutcomeReadError
from quant_platform_kit.ibkr.order_outcome import OutcomeFill, resolve_order_outcome, OrderOutcomeReadError

NOW = datetime(2026, 11, 3, 14, 31, tzinfo=timezone.utc)

Expand Down Expand Up @@ -320,3 +320,60 @@ def failed(*args, **kwargs):
assert decoder.logger is logger
finally:
logger.disabled = previous_disabled


def test_fill_contract_is_backward_compatible_and_json_serializable():
from dataclasses import asdict
import json
legacy = OutcomeFill('synthetic', 1, 52)
assert legacy.occurred_at is None and legacy.currency is None
assert json.loads(json.dumps(asdict(legacy)))['occurred_at'] is None


def test_execution_time_and_contract_currency_are_preserved():
from datetime import timedelta
execution = fill(time=NOW.astimezone(timezone(timedelta(hours=8))))
execution.contract.currency = 'USD'
result = resolve(Broker(executions=[execution]))
assert result.fills[0].occurred_at == '2026-11-03T14:31:00Z'
assert result.fills[0].currency == 'USD'
from dataclasses import asdict
import json
serialized = json.loads(json.dumps(asdict(result)))['fills'][0]
assert serialized['occurred_at'] == '2026-11-03T14:31:00Z'
assert serialized['currency'] == 'USD'


@pytest.mark.parametrize('currency', [None, '', 'usd', 'US', 'USDT', 'U$D', 'ÜSD', 123])
def test_absent_or_invalid_contract_currency_is_unknown(currency):
execution = fill()
execution.contract.currency = currency
result = resolve(Broker(executions=[execution]))
assert result.status == 'filled' and result.fills[0].currency is None


def test_execution_contract_currency_conflict_is_unknown():
first, second = fill(), fill()
first.contract.currency, second.contract.currency = 'USD', 'EUR'
assert resolve(Broker(executions=[first, second])).status == 'unknown'


def test_execution_time_conflict_is_unknown():
from datetime import timedelta
assert resolve(Broker(executions=[fill(), fill(time=NOW + timedelta(seconds=1))])).status == 'unknown'


def test_missing_contract_currency_attribute_is_unknown():
result = resolve(Broker(executions=[fill()]))
assert result.status == 'filled'
assert result.fills[0].currency is None


def test_equivalent_timezone_duplicate_execution_is_deduplicated():
from datetime import timedelta
first, second = fill(), fill(time=NOW.astimezone(timezone(timedelta(hours=8))))
first.contract.currency = second.contract.currency = 'USD'
result = resolve(Broker(executions=[first, second]))
assert result.status == 'filled'
assert len(result.fills) == 1
assert result.fills[0].occurred_at == '2026-11-03T14:31:00Z'
Loading