-
Notifications
You must be signed in to change notification settings - Fork 0
Add time-in-force and client order id support to IBKR client #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AlstonLi007
wants to merge
1
commit into
main
Choose a base branch
from
codex/extend-ibkrclient-to-support-time_in_force
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| """Interactive Brokers client utilities.""" | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import Any, Dict, Optional | ||
|
|
||
|
|
||
| @dataclass | ||
| class Order: | ||
| """A tiny representation of an Interactive Brokers order. | ||
|
|
||
| The real ``ib_insync`` order objects expose the attributes that are accessed | ||
| by the execution layer. For the purposes of unit testing we only model the | ||
| pieces that are manipulated by :class:`IBKRClient`. | ||
| """ | ||
|
|
||
| action: str | ||
| totalQuantity: float | ||
| orderType: str | ||
| tif: Optional[str] = None | ||
| lmtPrice: Optional[float] = None | ||
| auxPrice: Optional[float] = None | ||
| orderId: Optional[str] = None | ||
| transmit: bool = True | ||
|
|
||
|
|
||
| _ALLOWED_TIME_IN_FORCE: Dict[str, set[str]] = { | ||
| "market": {"DAY", "GTC", "IOC", "FOK"}, | ||
| "limit": {"DAY", "GTC", "IOC", "FOK", "GTD"}, | ||
| "stop": {"DAY", "GTC", "GTD"}, | ||
| "stop_limit": {"DAY", "GTC", "GTD"}, | ||
| "market_if_touched": {"DAY", "GTC", "GTD"}, | ||
| } | ||
|
|
||
|
|
||
| class IBKRClient: | ||
| """Minimal Interactive Brokers client wrapper used in tests.""" | ||
|
|
||
| def __init__(self, gateway: Any) -> None: | ||
| self._gateway = gateway | ||
|
|
||
| def place_order( | ||
| self, | ||
| contract: Any, | ||
| order_type: str, | ||
| side: str, | ||
| quantity: float, | ||
| *, | ||
| limit_price: Optional[float] = None, | ||
| stop_price: Optional[float] = None, | ||
| time_in_force: Optional[str] = None, | ||
| client_order_id: Optional[str] = None, | ||
| ) -> Dict[str, Any]: | ||
| """Create and submit an order to the Interactive Brokers gateway. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| contract | ||
| ``ib_insync`` contract object used by the order. | ||
| order_type | ||
| Textual order type, e.g. ``"market"`` or ``"limit"``. | ||
| side | ||
| ``"buy"`` or ``"sell"``. | ||
| quantity | ||
| Quantity that should be ordered. | ||
| limit_price, stop_price | ||
| Prices that are required for certain order types. | ||
| time_in_force | ||
| Time-in-force flag that should be pushed to the order. | ||
| client_order_id | ||
| Optional order id supplied by the caller. | ||
| """ | ||
|
|
||
| order = self._build_order( | ||
| order_type=order_type, | ||
| side=side, | ||
| quantity=quantity, | ||
| limit_price=limit_price, | ||
| stop_price=stop_price, | ||
| time_in_force=time_in_force, | ||
| client_order_id=client_order_id, | ||
| ) | ||
|
|
||
| trade = self._gateway.placeOrder(contract, order) | ||
|
|
||
| return { | ||
| "contract": contract, | ||
| "order": order, | ||
| "trade": trade, | ||
| "order_type": order.orderType, | ||
| "side": order.action, | ||
| "quantity": order.totalQuantity, | ||
| "time_in_force": order.tif, | ||
| "client_order_id": order.orderId, | ||
| } | ||
|
|
||
| def _build_order( | ||
| self, | ||
| *, | ||
| order_type: str, | ||
| side: str, | ||
| quantity: float, | ||
| limit_price: Optional[float], | ||
| stop_price: Optional[float], | ||
| time_in_force: Optional[str], | ||
| client_order_id: Optional[str], | ||
| ) -> Order: | ||
| """Create an order instance for the provided input.""" | ||
|
|
||
| normalized_type = order_type.lower() | ||
| if normalized_type not in _ALLOWED_TIME_IN_FORCE: | ||
| raise ValueError(f"Unsupported order type: {order_type}") | ||
|
|
||
| tif = time_in_force.upper() if time_in_force else None | ||
| if tif and tif not in _ALLOWED_TIME_IN_FORCE[normalized_type]: | ||
| raise ValueError( | ||
| "Time-in-force '%s' not allowed for order type '%s'" | ||
| % (tif, normalized_type) | ||
| ) | ||
|
|
||
| action = side.upper() | ||
| if action not in {"BUY", "SELL"}: | ||
| raise ValueError("Order side must be either 'buy' or 'sell'") | ||
|
|
||
| if normalized_type == "market": | ||
| order = Order(action=action, totalQuantity=quantity, orderType="MKT") | ||
| elif normalized_type == "limit": | ||
| if limit_price is None: | ||
| raise ValueError("Limit orders require a limit_price") | ||
| order = Order( | ||
| action=action, | ||
| totalQuantity=quantity, | ||
| orderType="LMT", | ||
| lmtPrice=limit_price, | ||
| ) | ||
| elif normalized_type == "stop": | ||
| if stop_price is None: | ||
| raise ValueError("Stop orders require a stop_price") | ||
| order = Order( | ||
| action=action, | ||
| totalQuantity=quantity, | ||
| orderType="STP", | ||
| auxPrice=stop_price, | ||
| ) | ||
| elif normalized_type == "stop_limit": | ||
| if limit_price is None or stop_price is None: | ||
| raise ValueError("Stop limit orders require limit_price and stop_price") | ||
| order = Order( | ||
| action=action, | ||
| totalQuantity=quantity, | ||
| orderType="STP LMT", | ||
| lmtPrice=limit_price, | ||
| auxPrice=stop_price, | ||
| ) | ||
| elif normalized_type == "market_if_touched": | ||
| if stop_price is None: | ||
| raise ValueError("Market-if-touched orders require stop_price") | ||
| order = Order( | ||
| action=action, | ||
| totalQuantity=quantity, | ||
| orderType="MIT", | ||
| auxPrice=stop_price, | ||
| ) | ||
| else: | ||
| raise ValueError(f"Unsupported order type: {order_type}") | ||
|
|
||
| order.tif = tif | ||
| if client_order_id is not None: | ||
| order.orderId = str(client_order_id) | ||
| order.transmit = True | ||
|
|
||
| return order | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| """Tests for the lightweight IBKR client wrapper.""" | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| import pathlib | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
||
| ROOT = pathlib.Path(__file__).resolve().parents[2] | ||
| if str(ROOT) not in sys.path: | ||
| sys.path.insert(0, str(ROOT)) | ||
|
|
||
| from openbb_lab.execution.ibkr_client import IBKRClient, Order | ||
|
|
||
|
|
||
| @dataclass | ||
| class DummyContract: | ||
| symbol: str | ||
|
|
||
|
|
||
| class GatewayStub: | ||
| def __init__(self) -> None: | ||
| self.last_order: Order | None = None | ||
| self.last_contract: DummyContract | None = None | ||
|
|
||
| def placeOrder(self, contract: DummyContract, order: Order): | ||
| self.last_order = order | ||
| self.last_contract = contract | ||
| return { | ||
| "orderType": order.orderType, | ||
| "action": order.action, | ||
| "time_in_force": order.tif, | ||
| "client_order_id": order.orderId, | ||
| } | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def client() -> IBKRClient: | ||
| return IBKRClient(GatewayStub()) | ||
|
|
||
|
|
||
| def test_place_order_propagates_time_in_force_and_client_order_id(client: IBKRClient): | ||
| contract = DummyContract(symbol="AAPL") | ||
|
|
||
| payload = client.place_order( | ||
| contract=contract, | ||
| order_type="limit", | ||
| side="buy", | ||
| quantity=10, | ||
| limit_price=123.45, | ||
| time_in_force="GTC", | ||
| client_order_id="ABC123", | ||
| ) | ||
|
|
||
| assert payload["time_in_force"] == "GTC" | ||
| assert payload["client_order_id"] == "ABC123" | ||
| assert payload["order"].tif == "GTC" | ||
| assert payload["order"].orderId == "ABC123" | ||
| assert payload["order"].transmit is True | ||
|
|
||
|
|
||
| def test_build_order_rejects_unsupported_time_in_force(client: IBKRClient): | ||
| with pytest.raises(ValueError): | ||
| client._build_order( # type: ignore[protected-access] | ||
| order_type="market", | ||
| side="buy", | ||
| quantity=1, | ||
| limit_price=None, | ||
| stop_price=None, | ||
| time_in_force="GTD", | ||
| client_order_id=None, | ||
| ) | ||
|
|
||
|
|
||
| def test_stop_limit_requires_prices(client: IBKRClient): | ||
| with pytest.raises(ValueError): | ||
| client._build_order( # type: ignore[protected-access] | ||
| order_type="stop_limit", | ||
| side="sell", | ||
| quantity=2, | ||
| limit_price=None, | ||
| stop_price=100, | ||
| time_in_force="DAY", | ||
| client_order_id=None, | ||
| ) | ||
|
|
||
| order = client._build_order( # type: ignore[protected-access] | ||
| order_type="stop_limit", | ||
| side="sell", | ||
| quantity=2, | ||
| limit_price=99.5, | ||
| stop_price=100, | ||
| time_in_force="DAY", | ||
| client_order_id="55", | ||
| ) | ||
|
|
||
| assert order.tif == "DAY" | ||
| assert order.orderId == "55" | ||
| assert order.lmtPrice == 99.5 | ||
| assert order.auxPrice == 100 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
client_order_idis passed, the wrapper stores it inorder.orderId. In the real IBKR APIorderIdmust remain a monotonically increasing integer obtained fromnextValidId; replacing it with an arbitrary string breaks order submission. IBKR exposes a separateclientOrderIdfield for caller-supplied identifiers that should be used instead, leavingorderIduntouched. As implemented, any order usingclient_order_idwill be rejected by the gateway.Useful? React with 👍 / 👎.