Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

WAP support on tickfilter bars #127

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

Conversation

gnzsnz
Copy link
Contributor

@gnzsnz gnzsnz commented Mar 12, 2025

Align ib_async.ticker.Ticker.Bar with TWS API IBApi.Bar class

Reference -> https://interactivebrokers.github.io/tws-api/classIBApi_1_1Bar.html

Change summary

  • add wap column
  • sort columns aligned with IBApi.Bar.
  • set Bar class slots=True
    • using pympler asizeof.asizeof(bar)=736
    • slot version asizeof.asizeof(bar)=152
  • include wap calculation on:
    • TimeBars
    • TickBars
    • VolumeBars
  • set ib_async.ticker.Ticker dataclasse as slots=True
    • according to pympler asizeof(Ticker) = 7200
    • slotted version asizeof(Ticker) = 3000
  • align functionality within Timebars, TickBars and VolumeBars. They all
    emit on new ticks self.bars.updateEvent.emit(self.bars, False) with parameter
    hasNewBar set False. Aligned with reqHistoricalData and reqRealTimeBars

Align `ib_async.ticker.Ticker.Bar` with TWS API `IBApi.Bar` class

Reference -> https://interactivebrokers.github.io/tws-api/classIBApi_1_1Bar.html

Change summary
- add `wap` column
- sort columns aligned with `IBApi.Bar` .
- set Bar class `slots=True`
    - using pympler asizeof.asizeof(bar)=736
    - slot version asizeof.asizeof(bar)=152
- include wap calculation on:
  - TimeBars
  - TickBars
  - VolumeBars
- set `ib_async.ticker.Ticker` dataclasse as slots=True
  - according to pympler  asizeof(Ticker) = 7200
  - slotted version asizeof(Ticker) = 3000
@@ -302,6 +304,11 @@ def on_source(self, time, price, size):
bar.high = max(bar.high, price)
bar.low = min(bar.low, price)
bar.close = price
# wap
if (bar.volume + size) == 0:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, how about:

new_volume = bar.volume + size
if new_volume != 0:  # Prevent division by zero in empty bar
    bar.wap = ((bar.wap * bar.volume) + (price * size)) / new_volume
bar.volume = new_volume

class Bar:
time: Optional[datetime]
open: float = nan
high: float = nan
low: float = nan
close: float = nan
volume: int = 0
wap: float = 0

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems lika "nan" is the convention for default. If you decide so, also conditions on ln 362 and 398 will need update to isNan(bar.wap).

Although looks like wap can only ever be nan/0 in TimeBar when bar gets created on timer without any input data, so maybe these conditions in Tick and Volume bars are unnecessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll do a few tests with nan, I just realize that using wap = 0 is running my plots

- set wap default value to `nan`
- manage first bar to avoit operations with `nan`
- align functionality within `Timebars`, `TickBars` and `VolumeBars`. They all
  emit on new ticks `self.bars.updateEvent.emit(self.bars, False)` with parameter
  `hasNewBar` set `False`. Aligned with `reqHistoricalData` and `reqRealTimeBars`
@gnzsnz
Copy link
Contributor Author

gnzsnz commented Mar 18, 2025

@pavel-slama please let me know your thoughts.

I have run this a few days with market data and it seems to be working fine.

@mattsta we are using

nan = float("nan") and ib_async.util.isNan, both are available on the standard library. this can be refactored. it requires changes in:

  • ib_async.util
  • ib_async.ticker
  • ib_async.objects
  • ib_async.wrapper

probably is too much to push it here, but i can push it into next

@pavel-slama
Copy link

pavel-slama commented Mar 18, 2025

@pavel-slama please let me know your thoughts.

I have run this a few days with market data and it seems to be working fine.

IMHO entirely correct solution would keep wap as nan if there's no data - as no price data makes wap undefined not 0. And if the plots should show 0 in such cases - then it should be the plotting code doing the nan -> 0 conversion. But I wouldn't block the merge because of that.

I can imagine it could screw up some code doing trading based on wap getting 0 when in fact there's no data.

(I don't have permissions in this repo anyway, just offering advice)

@gnzsnz
Copy link
Contributor Author

gnzsnz commented Mar 18, 2025

@pavel-slama please let me know your thoughts.
I have run this a few days with market data and it seems to be working fine.

IMHO entirely correct solution would keep wap as nan if there's no data - as no price data makes wap undefined not 0. And if the plots should show 0 in such cases - then it should be the plotting code doing the nan -> 0 conversion. But I wouldn't block the merge because of that.

I can imagine it could screw up some code doing trading based on wap getting 0 when in fact there's no data.

(I don't have permissions in this repo anyway, just offering advice)

that's what is doing right now. this is from my paper account where i'm running ib_async with this patch.

Bar(time=datetime.datetime(2025, 3, 18, 12, 49), open=20.85, high=20.85, low=20.84, close=20.84, volume=0.0, wap=nan, count=2))

I'm requesting midpoint data, which comes with no volume and wap=nan

@pavel-slama
Copy link

@pavel-slama please let me know your thoughts.
I have run this a few days with market data and it seems to be working fine.

IMHO entirely correct solution would keep wap as nan if there's no data - as no price data makes wap undefined not 0.

that's what is doing right now. this is from my paper account where i'm running ib_async with this patch.

Bar(time=datetime.datetime(2025, 3, 18, 12, 49), open=20.85, high=20.85, low=20.84, close=20.84, volume=0.0, wap=nan, count=2))

I'm requesting midpoint data, which comes with no volume and wap=nan

Then it's all good +1

@mattsta
Copy link
Contributor

mattsta commented Mar 18, 2025

Interesting improvement!

Some notes:

We do populate wap data in the decoder for BarData in some places (but it's just called average in some places for the object) — though, i'm not sure why it's not in the actual ticker.Bar object (I guess because only RealTimeBar is an API bar with live data?):

def historicalData(self, fields):
_, reqId, startDateStr, endDateStr, numBars, *fields = fields
get = iter(fields).__next__
for _ in range(int(numBars)):
bar = BarData(
date=get(),
open=float(get()),
high=float(get()),
low=float(get()),
close=float(get()),
volume=float(get()),
average=float(get()),
barCount=int(get()),
)
self.wrapper.historicalData(int(reqId), bar)
self.wrapper.historicalDataEnd(int(reqId), startDateStr, endDateStr)
def historicalDataUpdate(self, fields):
_, reqId, *fields = fields
get = iter(fields).__next__
bar = BarData(
barCount=int(get() or 0),
date=get(),
open=float(get() or 0),
close=float(get() or 0),
high=float(get() or 0),
low=float(get() or 0),
average=float(get() or 0),
volume=float(get() or 0),
)
self.wrapper.historicalDataUpdate(int(reqId), bar)

def realtimeBar(
self,
reqId: int,
time: int,
open_: float,
high: float,
low: float,
close: float,
volume: float,
wap: float,
count: int,
):
dt = datetime.fromtimestamp(time, self.defaultTimezone)
bar = RealTimeBar(dt, -1, open_, high, low, close, volume, wap, count)
bars = self.reqId2Subscriber.get(reqId)
if bars is not None:
bars.append(bar)
self.ib.barUpdateEvent.emit(bars, True)
bars.updateEvent.emit(bars, True)

50: self.wrap(
"realtimeBar", [int, int, float, float, float, float, float, float, int]
),

For the collective bars, it would take a little more work because the correct vwap calculation is sum(price * size) / sum(size), so we need to actually keep a full history of everything submitted to the collective bar, then when the bar is updated/complete, we calculate the total.

Also the average or wap field could be a @property decorator so it's calculated on-demand from the stored data (the bars list appended to).

So we can either update the wap/average calculation to only happen just before the "complete" time/tick/volume bar is emitted, or make it a dynamic implementation:

@property
def average(self) -> float:
    if self.ticks:
        pv = 0
        v = 0
        for tick in self.ticks:
            pv += tick.price * tick.size
            v += tick.size

        assert v
        return pv / v

    return float("nan")

something like that, but we'd also have to add a new field for tracking all the individual (price, size) tick updates in the collective bars too.

Or (if we have no use for the individual ticks for anything else), it could be done in more of an O(1) fashion by just tracking self.pv += price * size on every update, then before the bar is emitted just populating wap = self.pv / self.volume.

@gnzsnz
Copy link
Contributor Author

gnzsnz commented Mar 20, 2025

please let me know if more changes are needed to push this PR.

I do acknowledge that setting Ticker to __slots__=True might be pushing too much. I have tested it for many weeks without any problem, but i have not tested L2 data for example. When i measured the gains i told to myself this is too good to leave it for later.

# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants