From 20406210b36a060c988c6533a0a85dc8e111f62a Mon Sep 17 00:00:00 2001 From: goodboy Date: Tue, 15 Sep 2026 12:47:07 -0400 Subject: [PATCH] Clamp negative provider volume before FSP updates Clamp negative trade sizes and cumulative bar volume in sampling, and sanitize history and realtime volume in `tina_vwap()` and `dolla_vlm()`. Provider resets must not create negative outliers that hide the derived curve. Cover the history and tick paths. (this commit msg was generated in some part by `codex` using `gpt-6` (`openai`)) --- piker/data/_sampling.py | 12 ++++++++--- piker/fsp/_volume.py | 8 ++++---- tests/test_fsp_sync.py | 45 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/piker/data/_sampling.py b/piker/data/_sampling.py index 02420bb0..83afe321 100644 --- a/piker/data/_sampling.py +++ b/piker/data/_sampling.py @@ -670,7 +670,10 @@ async def sample_and_broadcast( ['open', 'high', 'low', 'volume'] ] - new_v: float = tick.get('size', 0) + new_v: float = max( + float(tick.get('size', 0)), + 0., + ) if v == 0 and new_v: # no trades for this bar yet so the open @@ -678,11 +681,14 @@ async def sample_and_broadcast( o = last if sum_tick_vlm: - volume = v + new_v + volume = max(float(v), 0.) + new_v else: # presume backend takes care of summing # it's own vlm - volume = quote['volume'] + volume = max( + float(quote['volume']), + 0., + ) shm.array[[ 'open', diff --git a/piker/fsp/_volume.py b/piker/fsp/_volume.py index 0f0479fb..fe9a53e0 100644 --- a/piker/fsp/_volume.py +++ b/piker/fsp/_volume.py @@ -90,7 +90,7 @@ async def tina_vwap( a = ohlcv.array chl3 = (a['close'] + a['high'] + a['low']) / 3 - v = a['volume'] + v = np.maximum(a['volume'], 0) h_vwap, cum_wp, cum_v = wap(chl3, v) @@ -112,7 +112,7 @@ async def tina_vwap( # ] # this computes tick-by-tick weightings from here forward - size = tick['size'] + size = max(float(tick['size']), 0.) price = tick['price'] v_tot += size @@ -147,7 +147,7 @@ async def dolla_vlm( ''' a = ohlcv.array chl3 = (a['close'] + a['high'] + a['low']) / 3 - v = a['volume'] + v = np.maximum(a['volume'], 0) # on first iteration yield history yield { @@ -170,7 +170,7 @@ async def dolla_vlm( ): # this computes tick-by-tick weightings from here forward - size = tick['size'] + size = max(float(tick['size']), 0.) price = tick['price'] li = ohlcv.index diff --git a/tests/test_fsp_sync.py b/tests/test_fsp_sync.py index cfdedc1b..078f468b 100644 --- a/tests/test_fsp_sync.py +++ b/tests/test_fsp_sync.py @@ -24,6 +24,7 @@ from piker.fsp._momo import ( wma, ) from piker.fsp._volume import ( + dolla_vlm, tina_vwap, ) from piker.accounting import MktPair @@ -415,6 +416,50 @@ def test_builtin_fsp_stream_contract(target: Fsp) -> None: trio.run(main) +def test_dolla_vlm_rejects_negative_trade_volume() -> None: + ''' + Keep a provider volume reset from hiding the zoomed-out curve. + + IB can emit a large negative trade size around a session closure. + The sampler previously stored that size as bar volume and the + dollar-volume history multiplied it by price. The resulting outlier + made `Viz.maxmin()` reject the entire downsampled range, leaving a + stale cached path or no visible curve. Seed one historical negative + volume and stream one negative trade tick, then prove both historical + and realtime dollar volume remain nonnegative. + + ''' + shm = OhlcvShm(length=4) + shm._array['volume'][:4] = [1, 2, -2_319_096, 3] + + async def source() -> AsyncIterator[FeedQuote]: + yield { + 'ticks': [{ + 'type': 'trade', + 'price': 29_495.75, + 'size': -2_319_096., + }], + } + + async def main() -> None: + stream = dolla_vlm.func( + source(), + cast(ShmArray, shm), + ) + history = await anext(stream) + assert isinstance(history, dict) + values = history['dolla_vlm'] + assert isinstance(values, np.ndarray) + assert values[2] == 0 + assert np.all(values >= 0) + + realtime = await anext(stream) + assert realtime == ('dolla_vlm', 0) + await stream.aclose() + + trio.run(main) + + class Viz: def __init__(self, name: str, shm: Shm) -> None: self.name: str = name