Normalize provider OHLCV for `NativeDB`

IB history frames omit the derived `index` and append `count`.
Normalize provider data to durable names, order, and dtypes before
merge, and reject fractional epoch truncation.

Also,
- map canonical storage fields into provider-specific SHM on reload
- select Polars columns by name instead of position
- exercise first-start and restart through actor, SHM, and Parquet

Prompt-IO: ai/prompt-io/opencode/20260727T212454Z_689df816_prompt_io.md

(this patch was generated in some part by `opencode` using `gpt-5.6-sol` (`openai`))
backfiller_deep_fixes
Gud Boi 2026-07-27 18:08:33 -04:00
parent 689df816d6
commit 19c6d98e6d
6 changed files with 327 additions and 10 deletions

View File

@ -0,0 +1,42 @@
---
model: gpt-5.6-sol
provider: openai
service: opencode
session: ses_0799212ebffe42arY96czXn89F
timestamp: 2026-07-27T21:24:54Z
git_ref: 689df816
scope: code
substantive: true
raw_file: 20260727T212454Z_689df816_prompt_io.raw.md
---
## Prompt
The user reran the known IB FQME on `backfiller_deep_fixes` and reported
that `publish_latest_frame()` still failed because NativeDB required an
`index` absent from IB's provider frame. The user called out the missed
tabular schema boundary and requested an actual end-to-end test suite
rather than storage fakes.
## Response summary
Repaired the IB-provider-to-NativeDB boundary by canonicalizing durable
fields and mapping reloads into provider-specific SHM buffers. Added a
local actor/SHM/Parquet integration regression covering first startup and
restart, plus focused conversion and timestamp-coercion tests. Two review
rounds found and resolved restart hydration and fractional-time hazards.
## Files changed
- `piker/storage/nativedb.py` - canonical storage and SHM field mapping
- `piker/tsp/_anal.py` - name-based Polars-to-NumPy conversion
- `tests/test_history_backfill.py` - actor/SHM/Parquet integration test
- `tests/test_storage_nativedb.py` - schema conversion regressions
- `ai/prompt-io/opencode/20260727T212454Z_689df816_prompt_io.raw.md`
- unedited response record
- `ai/prompt-io/opencode/20260727T212454Z_689df816_prompt_io.md`
- provenance metadata and response summary
## Human edits
None - generated changes have not been edited by the human.

View File

@ -0,0 +1,34 @@
---
model: gpt-5.6-sol
provider: openai
service: opencode
timestamp: 2026-07-27T21:24:54Z
git_ref: 689df816
diff_cmd: git diff HEAD~1..HEAD
---
> `git diff HEAD~1..HEAD -- piker/storage/nativedb.py piker/tsp/_anal.py`
Normalized index-less provider frames to NativeDB's canonical names,
order, and dtypes before merge and publication. Provider-only columns
are excluded from durable files, derived indexes are regenerated, and
fractional timestamps are rejected before integer conversion. Added a
canonical field map for hydrating provider-specific SHM buffers while
leaving provider-only fields at their defaults. Changed Polars-to-NumPy
conversion to select columns by field name rather than position.
> `git diff HEAD~1..HEAD -- tests/test_history_backfill.py tests/test_storage_nativedb.py`
Added deterministic coverage using IB's actual provider dtype and a real
local `tractor` root actor, IB-typed shared-memory buffers, NativeDB,
Parquet persistence, first-start publication, and restart hydration.
Added focused regressions for reordered/extra Polars columns and
fractional timestamp rejection.
Verification generated with the patch:
- NativeDB and history integration set: 23 passed
- Ruff: passed
- `git diff --check`: passed
- second adversarial review: no findings
- no live gateway or network tests run

View File

@ -58,6 +58,7 @@ from pathlib import Path
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import time import time
from bidict import bidict
import numpy as np import numpy as np
import polars as pl import polars as pl
from pendulum import ( from pendulum import (
@ -119,7 +120,13 @@ def unpack_fqme_from_parquet_filepath(path: Path) -> str:
return fqme return fqme
ohlc_key_map = None # Only hydrate fields shared by canonical storage and provider SHM.
# Provider-only fields remain at their buffer defaults on restart.
ohlc_key_map = bidict({
name: name
for name, _ in def_iohlcv_fields
if name != 'index'
})
class NativeStorageClient: class NativeStorageClient:
@ -261,6 +268,57 @@ class NativeStorageClient:
{}, {},
)[fqme] = df )[fqme] = df
def _canonicalize_ohlcv(
self,
df: pl.DataFrame,
) -> pl.DataFrame:
'''
Normalize a provider frame to the durable OHLCV schema.
Provider frames may omit the derived ``index`` field and add
provider-only fields. Durable frames contain only canonical
fields in their declared order.
'''
required: tuple[str, ...] = tuple(
name
for name, _ in def_iohlcv_fields
)
schema = {
name: pl.Int64 if field_type is int else pl.Float64
for name, field_type in def_iohlcv_fields
}
missing: set[str] = set(required).difference(df.columns)
missing.discard('index')
if missing:
raise ValueError(
f'OHLCV frame is missing columns: {sorted(missing)}'
)
times: np.ndarray = df['time'].to_numpy()
if (
not np.issubdtype(times.dtype, np.number)
or
not np.all(np.isfinite(times))
):
raise ValueError(
"OHLCV column 'time' must contain finite numeric data"
)
if np.any(times != np.floor(times)):
raise ValueError(
'OHLCV timestamps must use whole-second values'
)
return (
df
.with_columns(
pl.Series('index', np.arange(df.height))
)
.select(required)
.cast(schema)
)
async def read_ohlcv( async def read_ohlcv(
self, self,
fqme: str, fqme: str,
@ -325,9 +383,7 @@ class NativeStorageClient:
df: pl.DataFrame = tsp.np2pl(ohlcv) df: pl.DataFrame = tsp.np2pl(ohlcv)
else: else:
df = ohlcv df = ohlcv
df = df.with_columns( df = self._canonicalize_ohlcv(df)
pl.Series('index', np.arange(df.height))
)
self._validate_ohlcv(df) self._validate_ohlcv(df)
# TODO: in terms of managing the ultra long term data # TODO: in terms of managing the ultra long term data
@ -450,11 +506,14 @@ class NativeStorageClient:
incoming: pl.DataFrame = tsp.np2pl(ohlcv) incoming: pl.DataFrame = tsp.np2pl(ohlcv)
else: else:
incoming = ohlcv incoming = ohlcv
incoming = self._canonicalize_ohlcv(incoming)
self._validate_ohlcv(incoming) self._validate_ohlcv(incoming)
path: Path = self.mk_path(fqme, timeframe) path: Path = self.mk_path(fqme, timeframe)
if path.exists(): if path.exists():
stored: pl.DataFrame = pl.read_parquet(path) stored: pl.DataFrame = self._canonicalize_ohlcv(
pl.read_parquet(path)
)
merged: pl.DataFrame = pl.concat( merged: pl.DataFrame = pl.concat(
[stored, incoming], [stored, incoming],
how='diagonal_relaxed', how='diagonal_relaxed',

View File

@ -682,10 +682,7 @@ def pl2np(
df.height, df.height,
dtype, dtype,
) )
for field, col in zip( for field in dtype.fields:
dtype.fields, array[field] = df.get_column(field).to_numpy()
df.columns,
):
array[field] = df.get_column(col).to_numpy()
return array return array

View File

@ -3,18 +3,31 @@ Deterministic history-backfill regressions.
''' '''
from functools import partial from functools import partial
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from uuid import uuid4
import numpy as np import numpy as np
from pendulum import ( from pendulum import (
datetime, datetime,
from_timestamp, from_timestamp,
) )
import polars as pl
import pytest import pytest
import tractor
import trio import trio
from piker.brokers import DataUnavailable from piker.brokers import DataUnavailable
from piker.brokers.ib.api import (
_bar_load_dtype,
_ohlc_dtype,
)
from piker.data._sharedmem import maybe_open_shm_array
from piker.data._source import def_iohlcv_fields from piker.data._source import def_iohlcv_fields
from piker.storage.nativedb import (
NativeStorageClient,
ohlc_key_map,
)
from piker.tsp._history import ( from piker.tsp._history import (
notify_backfill, notify_backfill,
publish_latest_frame, publish_latest_frame,
@ -194,6 +207,118 @@ def test_latest_frame_is_persisted_before_shm() -> None:
assert events == ['storage', 'shm'] assert events == ['storage', 'shm']
def test_ib_latest_frame_round_trips_through_nativedb(
tmp_path: Path,
) -> None:
'''
Persist and reload IB's provider schema through history startup.
``publish_latest_frame()`` previously passed IB's index-less bars
directly to NativeDB, whose durable-schema validator rejected the
missing derived ``index``. IB also appends ``count`` after OHLCV,
which exposed positional Polars-to-NumPy conversion to field
corruption. Build the actual IB load dtype with distinct values,
run the real history publication, NativeDB Parquet, and ``ShmArray``
paths, then hydrate a fresh IB buffer from storage. Assertions prove
first-start publication preserves provider fields while restart maps
canonical fields and leaves provider-only ``count`` at its default.
'''
frame = np.zeros(
2,
dtype=np.dtype(_bar_load_dtype),
)
frame['time'] = [60, 120]
frame['open'] = [1.1, 2.1]
frame['high'] = [1.2, 2.2]
frame['low'] = [1.0, 2.0]
frame['close'] = [1.15, 2.15]
frame['volume'] = [10, 20]
frame['count'] = [3, 4]
storage = NativeStorageClient(tmp_path)
shm_key = f'test_ib_history_{uuid4().hex}'
mkt = SimpleNamespace(
fqme='mnq.cme.20260918.ib',
dst=SimpleNamespace(atype='continuous_future'),
src=SimpleNamespace(atype='fiat'),
get_fqme=lambda **kwargs: 'mnq.cme.20260918.ib',
)
async def main() -> None:
with trio.fail_after(2):
async with tractor.open_root_actor(
name=shm_key,
tpt_bind_addrs=[('127.0.0.1', 0)],
):
shm, opened = maybe_open_shm_array(
key=f'{shm_key}_first',
size=16,
dtype=np.dtype(_ohlc_dtype),
append_start_index=8,
)
restart_shm, restart_opened = maybe_open_shm_array(
key=f'{shm_key}_restart',
size=16,
dtype=np.dtype(_ohlc_dtype),
append_start_index=8,
)
assert opened
assert restart_opened
await publish_latest_frame(
storage=storage,
mkt=mkt,
shm=shm,
array=frame,
timeframe=60,
)
loaded = await storage.read_ohlcv(
mkt.fqme,
timeframe=60,
)
restart_shm.push(
loaded,
prepend=True,
field_map=ohlc_key_map,
)
stored = pl.read_parquet(
storage.mk_path(mkt.fqme, 60)
)
canonical = [
name
for name, _ in def_iohlcv_fields
]
canonical_schema = {
name: (
pl.Int64
if field_type is int
else pl.Float64
)
for name, field_type in def_iohlcv_fields
}
assert stored.columns == canonical
assert dict(stored.schema) == canonical_schema
assert list(loaded.dtype.fields) == canonical
assert loaded['index'].tolist() == [0, 1]
for field in canonical[1:]:
assert (
loaded[field].tolist()
==
frame[field].tolist()
)
assert (
restart_shm.array[field].tolist()
==
frame[field].tolist()
)
assert shm.array['count'].tolist() == [3, 4]
assert restart_shm.array['count'].tolist() == [0, 0]
trio.run(main)
def test_backfill_notification_timeout_is_bounded( def test_backfill_notification_timeout_is_bounded(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:

View File

@ -69,6 +69,42 @@ def test_numpy_and_polars_round_trip(
assert loaded['close'].tolist() == [1, 2, 3] assert loaded['close'].tolist() == [1, 2, 3]
def test_pl2np_maps_fields_by_name() -> None:
'''
Polars conversion must not depend on DataFrame column positions.
Provider and legacy Parquet frames can include extra columns or
present canonical fields in a different order. The old ``zip()``
conversion paired NumPy field names with DataFrame positions,
silently assigning unrelated values. Build distinct canonical
values, prepend provider-only ``count``, reverse canonical order,
and prove every structured-array field is selected by its name.
'''
expected = mk_ohlcv(
(60, 120),
(1.15, 2.15),
)
expected['open'] = [1.1, 2.1]
expected['high'] = [1.2, 2.2]
expected['low'] = [1.0, 2.0]
expected['volume'] = [10, 20]
canonical = [name for name, _ in def_iohlcv_fields]
reordered = (
tsp.np2pl(expected)
.with_columns(pl.Series('count', [3, 4]))
.select(['count', *reversed(canonical)])
)
actual = tsp.pl2np(
reordered,
dtype=expected.dtype,
)
for field in canonical:
assert actual[field].tolist() == expected[field].tolist()
def test_update_preserves_history_and_resolves_conflicts( def test_update_preserves_history_and_resolves_conflicts(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
@ -262,6 +298,30 @@ def test_replacement_rejects_invalid_timestamps(
assert not client.mk_path('x.test', 60).exists() assert not client.mk_path('x.test', 60).exists()
def test_fractional_timestamps_are_not_truncated(
tmp_path: Path,
) -> None:
'''
Durable timestamp coercion must not silently alter provider data.
NativeDB declares integer epoch seconds, while IB delivers its
timestamps in a floating dtype. Casting before validation truncated
fractional values and could collapse distinct rows at one second.
Supply otherwise-valid half-second values and prove the write rejects
them before publishing any Parquet path.
'''
client = NativeStorageClient(tmp_path)
frame = tsp.np2pl(mk_ohlcv((60, 120))).with_columns(
pl.Series('time', [60.5, 120.5])
)
with pytest.raises(ValueError, match='whole-second'):
run(client.write_ohlcv('x.test', frame, 60))
assert not client.mk_path('x.test', 60).exists()
def test_write_rejects_invalid_schema_and_values( def test_write_rejects_invalid_schema_and_values(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None: