Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ec8f145
feat: added version 2 of IbkrWsClient (still WIP) - refactoring the t…
Voyz Apr 29, 2026
ed19456
chore: updated to requests>=2.33 and added pydantic>=2.13
Voyz Apr 29, 2026
d263c0f
refactor(queue_controller): generic T is bound to Hashable instead of…
Voyz Apr 29, 2026
3d8fbe8
refactor(ws_v2): added binding_key (to Subscriptions and resolution) …
Voyz Apr 29, 2026
6c42477
fix(ws_v2): small fixes
Voyz Apr 29, 2026
772d985
fix(ws_v2): small fixes
Voyz Apr 29, 2026
7d25093
feat(ws_v2): added subscription handlers
Voyz Apr 30, 2026
9ecf495
Merge remote-tracking branch 'origin/feat/ws_v2' into feat/ws_v2
Voyz Apr 30, 2026
f481122
feat(ws_v2): added health checks handling and resets
Voyz May 1, 2026
40e8ea5
chore: updated wait_until's time usage from time.time to time.monotonic
Voyz May 1, 2026
87dbcff
feat(ws_v2): added WsDegraded event, removed WsReconnect and unified …
Voyz May 1, 2026
d693b10
feat(ws_v2): added automated handling of MarketHistory unsubscriptions
Voyz May 2, 2026
8aeb3e7
feat(ws_v2): added AsyncQueue and subscription expires
Voyz May 3, 2026
db5caa6
refactor(logs): project_logger now accepts both filepath and a normal…
Voyz May 3, 2026
68db65e
fix(ws_v2): small fixes
Voyz May 3, 2026
93c93fa
feat(ws_v2): added skip_utf8_validation, added wait_for(handles), ren…
Voyz May 3, 2026
da690bf
feat(ws_v2): deprecated IbkrWsKey - WsEvent types are used instead
Voyz May 3, 2026
90b9b22
chore(ws_v2): updated API with ws_v2
Voyz May 3, 2026
a68135d
chore(ws_v2): small reformatting
Voyz May 3, 2026
8749c2b
requirements: updated websocket-client to >=1.9 (from >=1.7)
Voyz May 3, 2026
77931ff
fix(logs): fixed project_logger incorrectly testing filepath
Voyz May 3, 2026
d33ddc0
fix(logs): fixed project_logger incorrectly testing filepath 2
Voyz May 3, 2026
ee6a601
chore: small reformats
Voyz May 3, 2026
68ecb8f
refactor(ws_v2): normalize event imports through `ibind.events`, re-e…
Voyz May 10, 2026
bffdbe5
fix(ws_transport): fixed return type of fetch_cookie for Python <=3.10
Voyz May 10, 2026
4f88a35
refactor(ws_v2): ws_v2.events was renamed to ws_v2._ws_events to indi…
Voyz May 10, 2026
d864d68
refactor(ws_v2): ws_v2.subscriptions was renamed to ws_v2.ws_subscrip…
Voyz May 10, 2026
295f253
docs: add TESTING.md
Voyz May 10, 2026
e612076
test: add test_ws_events.py
Voyz May 10, 2026
a32e7da
docs: added docstrings to ws_subscriptions.py
Voyz May 10, 2026
530438a
test: fixed ruff checks
Voyz May 10, 2026
fcedb06
test: add test_ws_subscriptions_u.py
Voyz May 10, 2026
d63093f
Merge remote-tracking branch 'origin/feat/ws_v2' into codex-fixes
tranceporter May 13, 2026
1e49a0c
Fix OAuth and websocket v2 regressions
tranceporter May 13, 2026
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ __pycache__
ibind.egg-info
build/docs/.generated-files.txt
build/docs/mkdocs.yml
build/lib/

.venv
.env
*.env
.vscode
.DS_Store
venv
.coverage
.coverage
88 changes: 88 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Testing Guide

This document defines how tests are chosen, written, and evaluated.

---

## Testing philosophy

- Use commentary with `## Arrange` `## Act` and `## Assert` sections for test structure.
- Tests exist to lock behaviour, not to chase coverage.
- Avoid tests that duplicate what the language/runtime already guarantees.
- Use fixtures for setup/teardown of test state.
- Mock internal dependencies for unit tests. Mock external dependencies for integration tests.
- Prefer integration tests for verifying component boundaries and data flow.
- Capture and assert on logs for error and warning conditions.


## Test Type Structure

Tests are organized into three main categories:

```
test/
├── unit/ # Fast, isolated tests for core logic
├── integration/ # Multi-component tests
└── manual/ # Manual and performance tests
```


## Test types and boundaries

Use the lightest test type that still provides confidence.

### Unit tests
Use when:
- logic is isolated and deterministic
- behaviour can be validated without wiring other components

Guidelines:
- No network, filesystem, threads, or time dependence.
- Mock only at clear boundaries; do not mock internals of the unit under test.
- Data should be small, synthetic, and explicit.
- Prefer clarity over clever parametrisation.

### Integration tests
Use when:
- correctness depends on interaction between components
- data flow or ordering matters

Guidelines:
- Mock only outside the test boundary (eg. broker, network).
- Use realistic but minimal fixtures.
- Allow threads/timers only if they are part of the behaviour being tested.
- Failures should clearly indicate which interaction broke.

### Manual / performance tests
Use when:
- validating full-system flows
- measuring throughput, latency, or concurrency
- interacting with real or near-real external systems

Guidelines:
- Never run automatically in CI.
- Keep secrets out of test code.
- Prefer recorded or replayable inputs where possible.
- Treat results as diagnostic, not pass/fail gates.


## Choosing what to test

Test:
- decision logic
- state transitions
- boundary conditions
- error and warning paths
- behaviour that has broken before

Do not test:
- trivial getters/setters
- pure delegation
- obvious library behaviour
- formatting or logging text unless it signals correctness


## Running tests

- Prefer running the smallest relevant subset while iterating.
- Run broader suites when touching core or high-risk code.
2 changes: 1 addition & 1 deletion examples/rest_06_options_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@

response = client.place_order(order_request, answers, account_id).data

print(response)
print(response)
117 changes: 117 additions & 0 deletions examples/ws_04_ws_v2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
WebSocket Intermediate

In this example we:

* Demonstrate subscription to multiple channels
* Utilise queue accessors
* Use the 'signal' module to ensure we unsubscribe and shutdown upon the program termination

Assumes the Gateway is deployed at 'localhost:5000' and the IBIND_ACCOUNT_ID and IBIND_CACERT environment variables have been set.
"""

import os
import time
from typing import List

from ibind import events, IbkrWsClientV2, LogSink, QueueSink, CallbackSink, CompositeSink, ibind_logs_initialize
from ibind.subscriptions import MarketDataSubscription, OrdersSubscription, AccountLedgerSubscription, AccountSummarySubscription, PnlSubscription, TradesSubscription, MarketHistorySubscription, SubscriptionHandle

ibind_logs_initialize(log_to_file=False, log_level='INFO')

account_id = os.getenv('IBIND_ACCOUNT_ID', '[YOUR_ACCOUNT_ID]')
cacert = os.getenv('IBIND_CACERT', False) # insert your cacert path here

# Queue Sink - queue-based event consumer
queue_sink = QueueSink()

# Callback Sink - callback-based event consumer
callback_sink = CallbackSink()


def on_market_data(event: events.MarketData):
print(event)

def on_market_history(event: events.MarketHistory):
print(event)

def on_lifecycle(event: events.LifecycleEvent):
print(event)

callback_sink.on(events.MarketData, on_market_data)
callback_sink.on(events.MarketHistory, on_market_data)
callback_sink.on(events.WsOpen, on_lifecycle)
callback_sink.on(events.WsClose, on_lifecycle)
callback_sink.on(events.WsError, on_lifecycle)
callback_sink.on(events.WsAuthenticated, on_lifecycle)
callback_sink.on(events.WsReady, on_lifecycle)
callback_sink.on(events.WsDegraded, on_lifecycle)

# Log Sink - useful for debugging
log_sink = LogSink()

# Composite Sink - allows us to use all above sinks at once
composite_sink = CompositeSink(callback_sink, log_sink)

# ws_client = IbkrWsClient(cacert=cacert, account_id=account_id)
# ws_client = IbkrWsClientV2(cacert=cacert, account_id=account_id, sink=LogSink())
ws_client = IbkrWsClientV2(cacert=cacert, account_id=account_id, sink=queue_sink)


ws_client.start()

as_sub = AccountSummarySubscription(account_id=account_id)
al_sub = AccountLedgerSubscription(account_id=account_id)
md_sub = MarketDataSubscription(conid='265598', fields=["31", "84", "86"], expiry_seconds=30)
mh_sub = MarketHistorySubscription(conid='265598')
or_sub = OrdersSubscription()
# pl_sub = PriceLadderSubscription(conid='265598', account_id=account_id, exchange='SMART')
pnl_sub = PnlSubscription()
tr_sub = TradesSubscription()
subs = [
# as_sub,
# al_sub,
md_sub,
# mh_sub,
# or_sub,
# pnl_sub,
# tr_sub
]

sub_handles: List[SubscriptionHandle] = []
for sub in subs:
handle = ws_client.subscribe(sub)
handle.wait()
sub_handles.append(handle)

for handle in sub_handles:
success = handle.wait(timeout=10)
if not success:
print('Subscription not active within 10 seconds')

try:
while ws_client.is_running():
for sub in subs:
while not queue_sink.empty(sub.event_type):
ev = queue_sink.get(sub.event_type)
print(ev)

time.sleep(1)
except KeyboardInterrupt:
print('Interrupt')

for handle in sub_handles:
unsub_handle = handle.unsubscribe()
success = unsub_handle.wait(timeout=10)
if not success:
print('Subscription not unsubscribed within 10 seconds')

# unsub_handles = []
# for sub in subs:
# handle = ws_client.unsubscribe(sub)
# unsub_handles.append(handle)
#
# for handle in unsub_handles:
# handle.wait(10)

ws_client.shutdown()
17 changes: 16 additions & 1 deletion ibind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
from ibind.support.errors import ExternalBrokerError
from ibind.support.logs import ibind_logs_initialize
from ibind.support.py_utils import execute_in_parallel
from ibind import events, subscriptions
from ibind.ibkr_ws_v2.ibkr_ws_client_v2 import IbkrWsClientV2
from ibind.ws_v2._ws_events import LogSink, QueueSink, CallbackSink, CompositeSink, NoopSink, EventSink
from ibind.ws_v2.ws_subscriptions import SubscriptionHandle


__all__ = [
'ibind_logs_initialize',
Expand All @@ -28,7 +33,17 @@
'QueueAccessor',
'execute_in_parallel',
'ExternalBrokerError',
'question_type_to_message_id'
'question_type_to_message_id',
'events',
'subscriptions',
'IbkrWsClientV2',
'EventSink',
'NoopSink',
'LogSink',
'QueueSink',
'CallbackSink',
'CompositeSink',
'SubscriptionHandle',
]

# patch_dotenv()
5 changes: 2 additions & 3 deletions ibind/base/queue_controller.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from enum import Enum
from queue import Queue, Empty
from typing import TypeVar, Generic, Any
from typing import TypeVar, Generic, Any, Hashable

from ibind.support.py_utils import ensure_list_arg, OneOrMany

T = TypeVar('T', str, Enum)
T = TypeVar('T', bound=Hashable)


class QueueAccessor(Generic[T]): # pragma: no cover
Expand Down
41 changes: 22 additions & 19 deletions ibind/base/rest_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ def copy(self, data: Optional[Union[list, dict]] = UNDEFINED, request: Optional[
Returns:
Result: A new Result instance with the specified modifications.
"""
return Result(data=data if data is not UNDEFINED else self.data.copy(), request=request if request is not UNDEFINED else self.request.copy())
def _copy(value):
return value.copy() if hasattr(value, 'copy') else value

return Result(data=data if data is not UNDEFINED else _copy(self.data), request=request if request is not UNDEFINED else _copy(self.request))


def pass_result(data: dict, old_result: Result) -> Result:
Expand Down Expand Up @@ -117,6 +120,7 @@ def __init__(

self.use_session = use_session
self._auto_recreate_session = auto_recreate_session
self._closed = False

if use_session:
self.make_session()
Expand All @@ -139,7 +143,7 @@ def logger(self):
self._make_logger()
return self._logger

def _get_headers(self, request_method: str, request_url: str):
def _get_headers(self, request_method: str, request_url: str, request_params: dict = None):
return {}

def get(
Expand Down Expand Up @@ -224,23 +228,24 @@ def _request(
endpoint = endpoint.lstrip('/')
url = f'{base_url}{endpoint}'

headers = self._get_headers(request_method=method, request_url=url)
headers = {**headers, **(extra_headers or {})}

# we want to allow default values used by IBKR, so we remove all None parameters
kwargs = filter_none(kwargs)

# choose which function should be used to make a reqeust based on use_session field
if self.use_session and self._session is not None:
request_function = self._session.request
else:
request_function = requests.request

if request_function is None:
_LOGGER.warning(f'{self}: an attempt was made to create a request with no valid session.')
request_params = kwargs.get('params') if method.upper() == 'GET' else None
headers = self._get_headers(request_method=method, request_url=url, request_params=request_params)
headers = {**headers, **(extra_headers or {})}

# we repeat the request attempts in case of ReadTimeouts up to max_retries
for attempt in range(self._max_retries + 1):
# choose which function should be used to make a request based on the current session
if self.use_session and self._session is not None:
request_function = self._session.request
else:
request_function = requests.request

if request_function is None:
_LOGGER.warning(f'{self}: an attempt was made to create a request with no valid session.')

if log:
self.logger.info(f'{method} {url} {kwargs}{" (attempt: " + str(attempt) + ")" if attempt > 0 else ""}')

Expand Down Expand Up @@ -308,6 +313,9 @@ def close_session(self):
self._session = None

def close(self):
if getattr(self, '_closed', False):
return
self._closed = True
self.close_session()


Expand All @@ -328,12 +336,7 @@ def register_shutdown_handler(self):
existing_handler_int = signal.getsignal(signal.SIGINT)
existing_handler_term = signal.getsignal(signal.SIGTERM)

self._closed = False

def _close_handler():
if self._closed:
return
self._closed = True
self.close()

def _signal_handler(signum, frame):
Expand All @@ -356,4 +359,4 @@ def _signal_handler(signum, frame):
atexit.register(_close_handler)

def __str__(self):
return f'{self.__class__.__qualname__}'
return f'{self.__class__.__qualname__}'
Loading