Compare commits
No commits in common. "2aa66c2cf6c76b8e270af9367d27ca16f63e7024" and "0846cbd4a4197e47415689cf5e9d146ba3174c32" have entirely different histories.
2aa66c2cf6
...
0846cbd4a4
|
|
@ -47,7 +47,7 @@ that's why it's like 1000x faster ya know?"
|
||||||
- Wrap all code symbols: `function()`,
|
- Wrap all code symbols: `function()`,
|
||||||
`ClassName`, `field_name`
|
`ClassName`, `field_name`
|
||||||
- File paths: `piker/ui/_remote_ctl.py`
|
- File paths: `piker/ui/_remote_ctl.py`
|
||||||
- Commands: `git status`, `piker store shm`
|
- Commands: `git status`, `piker store ldshm`
|
||||||
|
|
||||||
**Explain like you're pair programming:**
|
**Explain like you're pair programming:**
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,8 @@ Deterministic or local first-pass targets:
|
||||||
|
|
||||||
- `tests/test_watchlists.py`
|
- `tests/test_watchlists.py`
|
||||||
- `tests/test_storage_audit.py`
|
- `tests/test_storage_audit.py`
|
||||||
- `tests/test_store_cli.py`
|
|
||||||
- `tests/test_backfill_audit_snippet.py`
|
- `tests/test_backfill_audit_snippet.py`
|
||||||
- `tests/test_ib_history.py`
|
- `tests/test_ib_history.py`
|
||||||
- `tests/test_ib_method_proxy.py`
|
|
||||||
- `tests/test_history_backfill.py`
|
- `tests/test_history_backfill.py`
|
||||||
- `tests/test_ldshm.py`
|
- `tests/test_ldshm.py`
|
||||||
- `tests/test_accounting.py::test_account_file_default_empty`
|
- `tests/test_accounting.py::test_account_file_default_empty`
|
||||||
|
|
@ -135,8 +133,8 @@ Require explicit authorization before running:
|
||||||
`tests/test_cli.py` is currently hard-skipped. It is not an active CLI
|
`tests/test_cli.py` is currently hard-skipped. It is not an active CLI
|
||||||
regression gate.
|
regression gate.
|
||||||
|
|
||||||
Never execute `piker store anal` or `piker store shm --write-parquet`
|
Never execute `piker store anal` or `piker store ldshm` as tests. They are
|
||||||
as tests. They are mutating or interactive operational commands.
|
mutating or interactive operational commands.
|
||||||
|
|
||||||
## Project-Specific Flags And Backend Matrix
|
## Project-Specific Flags And Backend Matrix
|
||||||
|
|
||||||
|
|
@ -194,12 +192,10 @@ tests/
|
||||||
test_ems.py actor, EMS, and paper-position behavior
|
test_ems.py actor, EMS, and paper-position behavior
|
||||||
test_feeds.py live Binance/Kraken feeds and shared memory
|
test_feeds.py live Binance/Kraken feeds and shared memory
|
||||||
test_ib_history.py deterministic IB history request formatting
|
test_ib_history.py deterministic IB history request formatting
|
||||||
test_ib_method_proxy.py deterministic IB asyncio proxy routing
|
|
||||||
test_history_backfill.py deterministic history/SHM orchestration
|
test_history_backfill.py deterministic history/SHM orchestration
|
||||||
test_ldshm.py SHM unpublished-slot guard
|
test_ldshm.py ldshm unpublished-slot guard
|
||||||
test_questrade.py obsolete credentialed tests; skipped
|
test_questrade.py obsolete credentialed tests; skipped
|
||||||
test_services.py pikerd/datad/feed/EMS actor lifecycle
|
test_services.py pikerd/datad/feed/EMS actor lifecycle
|
||||||
test_store_cli.py storage command help and diagnostics UX
|
|
||||||
test_storage_audit.py read-only NativeDB audit and JSON CLI
|
test_storage_audit.py read-only NativeDB audit and JSON CLI
|
||||||
test_backfill_audit_snippet.py
|
test_backfill_audit_snippet.py
|
||||||
disposable xonsh qualification helpers
|
disposable xonsh qualification helpers
|
||||||
|
|
@ -212,12 +208,10 @@ tests/
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `piker/watchlists/` | `tests/test_watchlists.py` | CLI suite is skipped |
|
| `piker/watchlists/` | `tests/test_watchlists.py` | CLI suite is skipped |
|
||||||
| `piker/storage/_audit.py`, `piker/storage/cli.py` | `tests/test_storage_audit.py` | direct Typer app, no actor |
|
| `piker/storage/_audit.py`, `piker/storage/cli.py` | `tests/test_storage_audit.py` | direct Typer app, no actor |
|
||||||
| `piker/storage/cli.py` command UX | `tests/test_store_cli.py` | fake SHM/runtime, no mutation |
|
|
||||||
| `snippets/nativedb_backfill_audit.xsh` | `tests/test_backfill_audit_snippet.py` | disposable paths only |
|
| `snippets/nativedb_backfill_audit.xsh` | `tests/test_backfill_audit_snippet.py` | disposable paths only |
|
||||||
| `piker/brokers/ib/api.py`, `feed.py` history | `tests/test_ib_history.py` | fake client, no network |
|
| `piker/brokers/ib/api.py`, `feed.py` history | `tests/test_ib_history.py` | fake client, no network |
|
||||||
| `piker/brokers/ib/api.py` method proxy | `tests/test_ib_method_proxy.py` | fake channel, no network |
|
|
||||||
| `piker/tsp/_history.py` | `tests/test_history_backfill.py` | fake provider/storage/SHM |
|
| `piker/tsp/_history.py` | `tests/test_history_backfill.py` | fake provider/storage/SHM |
|
||||||
| `piker/storage/cli.py` SHM null-slot guard | `tests/test_ldshm.py` | synthetic timestamps, no SHM mutation |
|
| `piker/storage/cli.py` ldshm null-slot guard | `tests/test_ldshm.py` | synthetic timestamps, no SHM mutation |
|
||||||
| `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch |
|
| `piker/config.py` | `test_account_file_default_empty` | root-network test has a known mismatch |
|
||||||
| `piker/accounting/` | targeted accounting node | some cases use live/configured state |
|
| `piker/accounting/` | targeted accounting node | some cases use live/configured state |
|
||||||
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | GUI/config-isolated opt-in |
|
| `piker/ui/_style.py`, `piker/ui/qt.py` | `tests/test_dpi_font.py` | GUI/config-isolated opt-in |
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: ses_0799212ebffe42arY96czXn89F
|
|
||||||
timestamp: 2026-07-31T16:52:15Z
|
|
||||||
git_ref: 0846cbd4
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260731T165215Z_0846cbd4_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Evaluate and update `piker store` so bare groups and endpoints immediately
|
|
||||||
show help; prepare for eventual optional fuzzy selection in `anal` and
|
|
||||||
`ldshm`; aggregate recent time-series diagnostics into CLI endpoints. The
|
|
||||||
user selected the safe-foundation scope.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Added immediate help UX, exact NativeDB and SHM identities, read-only
|
|
||||||
`series` and `shm` diagnostics, selector-ready exact SHM names, and robust
|
|
||||||
discovery/attachment. Fixed related `ldshm` option and no-match failures.
|
|
||||||
Deferred optional `fzf` and mutation-default changes.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `piker/storage/cli.py` - help UX and read-only series/SHM diagnostics.
|
|
||||||
- `piker/storage/nativedb.py` - canonical compound series identities.
|
|
||||||
- `piker/tsp/_history.py` - exact SHM discovery and dtype resolution.
|
|
||||||
- `piker/data/_sharedmem.py` - attach-only existing-segment helper.
|
|
||||||
- `piker/tsp/__init__.py` - SHM identity helper exports.
|
|
||||||
- `tests/test_store_cli.py` - command UX and endpoint regressions.
|
|
||||||
- `tests/test_ldshm.py` - SHM diagnostics and operational regressions.
|
|
||||||
- `tests/test_storage_nativedb.py` - series parsing/index regressions.
|
|
||||||
- `docs/manual_backfill_qualification.rst` - triage command documentation.
|
|
||||||
- `.claude/skills/run-tests/test-harness-reference.md` - test mapping.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None - generated output remains uncommitted.
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-07-31T16:52:15Z
|
|
||||||
git_ref: 0846cbd4
|
|
||||||
diff_cmd: git diff HEAD~1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
The user requested an evaluation and possible update of `piker store`,
|
|
||||||
including immediate help output when a group or endpoint is invoked with
|
|
||||||
no arguments. The user also proposed eventual optional `fzf` selection for
|
|
||||||
`anal` and `ldshm`, and authorized aggregating the recent time-series and
|
|
||||||
SHM debugging work into CLI endpoints. After reviewing three scopes, the
|
|
||||||
user selected the recommended safe foundation rather than implementing
|
|
||||||
interactive fuzzy selection or changing mutation defaults immediately.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/storage/cli.py`
|
|
||||||
|
|
||||||
Generated no-argument help behavior for the `store` group and every
|
|
||||||
endpoint. Added read-only `series` and `shm` commands, JSON and Rich output,
|
|
||||||
exact `--shm-name` plumbing, SHM cadence/invalid/order/gap summaries, and
|
|
||||||
safe corrupt-epoch formatting. Fixed existing `ldshm` no-write/no-reload
|
|
||||||
control flow and no-match debugger behavior.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/storage/nativedb.py`
|
|
||||||
|
|
||||||
Generated canonical `NativeSeriesRef` parsing and deterministic listing.
|
|
||||||
Changed the internal index to preserve `(fqme, period)` identities while
|
|
||||||
keeping unique-FQME compatibility listing and cardinality. Hardened
|
|
||||||
malformed, sidecar, Unicode-period, symlink, and traversal handling.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/tsp/_history.py`
|
|
||||||
|
|
||||||
Generated exact `ShmBufferRef` parsing and stable discovery, replacing
|
|
||||||
unsafe substring globbing. Added dtype resolution that mirrors history
|
|
||||||
allocation, exact-ref attachment, disappearance handling, and optional
|
|
||||||
exact-name iteration.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/data/_sharedmem.py`
|
|
||||||
|
|
||||||
Generated attach-only SHM access which never creates a segment when a
|
|
||||||
discovered object disappears.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/tsp/__init__.py`
|
|
||||||
|
|
||||||
Exported selector-ready SHM identity, parsing, listing, and dtype helpers.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_store_cli.py`
|
|
||||||
|
|
||||||
Generated command-level help, exact durable-series JSON, read-only SHM JSON,
|
|
||||||
and unknown exact-name regressions.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_ldshm.py`
|
|
||||||
|
|
||||||
Generated SHM identity, summary, ordering, dtype, forged-name, disabled
|
|
||||||
mutation-flag, and gap-markup regressions.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_storage_nativedb.py`
|
|
||||||
|
|
||||||
Generated compound index, canonical parser, malformed filename, and dotted
|
|
||||||
FQME regressions.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- docs/manual_backfill_qualification.rst`
|
|
||||||
|
|
||||||
Documented `store series` and `store shm` as separate read-only durable and
|
|
||||||
live-memory triage layers.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- .claude/skills/run-tests/test-harness-reference.md`
|
|
||||||
|
|
||||||
Registered the deterministic storage CLI UX target and mapping.
|
|
||||||
|
|
||||||
Verification output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
........................................................................ [ 71%]
|
|
||||||
............................. [100%]
|
|
||||||
101 passed in 1.72s
|
|
||||||
```
|
|
||||||
|
|
||||||
Real `piker store` and `piker store audit` invocations printed complete
|
|
||||||
help without `--help`, exiting with Click's normal no-args help status 2.
|
|
||||||
`git diff --check` passed. Ruff was unavailable in the existing worktree
|
|
||||||
environment.
|
|
||||||
|
|
||||||
Adversarial review drove fixes for provider dtype reconstruction, observed
|
|
||||||
versus expected cadence, exact SHM grammar, discovery races, Typer direct
|
|
||||||
defaults, malformed NativeDB periods, compound cardinality compatibility,
|
|
||||||
undefined `ldshm` reload payloads, no-match debugger hangs, ordering
|
|
||||||
corruption reporting, and dtype precedence. Final review found no issues.
|
|
||||||
|
|
||||||
Optional external `fzf` or built-in numbered selection remains deferred.
|
|
||||||
The exact series and SHM identities added here are the intended foundation
|
|
||||||
for that later patch.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: ses_0799212ebffe42arY96czXn89F
|
|
||||||
timestamp: 2026-08-01T01:53:21Z
|
|
||||||
git_ref: 74ea2152
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260801T015321Z_74ea2152_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Make `piker store ls` default to all NativeDB series as a compact counterpart
|
|
||||||
to detailed `series`, with sensible no-flag output and a visible help
|
|
||||||
description. Consolidate SHM inspection and repair under canonical `shm`,
|
|
||||||
because attachment to volatile segments is implied, and make inspection the
|
|
||||||
safe default while retaining explicit persistence and reload controls.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Replaced backend-oriented `ls` with compact NativeDB discovery and made both
|
|
||||||
listing commands default to all series. Consolidated SHM behavior under a
|
|
||||||
read-only-by-default `shm` command, guarded explicit repair options, updated
|
|
||||||
executable references and docs, and added command regressions.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `piker/storage/cli.py` - listing defaults and unified SHM command modes.
|
|
||||||
- `tests/test_store_cli.py` - listing, help, and SHM mode regressions.
|
|
||||||
- `tests/test_ldshm.py` - explicit persistence-path regressions.
|
|
||||||
- `docs/manual_backfill_qualification.rst` - revised safe CLI workflow.
|
|
||||||
- `snippets/claude_debug_helper.py` - explicit SHM persistence commands.
|
|
||||||
- `.claude/skills/run-tests/test-harness-reference.md` - safety and test map.
|
|
||||||
- `.claude/skills/piker-slang/examples.md` - canonical command example.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None - generated output incorporates the user's design refinements.
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-08-01T01:53:21Z
|
|
||||||
git_ref: 74ea2152
|
|
||||||
diff_cmd: git diff HEAD~1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
The user requested command-surface refinements after reviewing the new
|
|
||||||
storage CLI. Bare `ls` should default to all series and act as a compact
|
|
||||||
form of detailed `series`; commands should produce sensible default output
|
|
||||||
without requiring trigger flags; and `ls` needed a parent-help description.
|
|
||||||
The user first proposed folding read-only SHM inspection into `ldshm -r`,
|
|
||||||
then clarified that `shm` is the better canonical command because attaching
|
|
||||||
to volatile segments is implied by the resource name.
|
|
||||||
|
|
||||||
The resulting design uses `piker store ls` as a compact NativeDB FQME and
|
|
||||||
period view, while `piker store series` reports each exact sub-series with
|
|
||||||
size and path details. Both default to all records and accept an optional
|
|
||||||
FQME substring.
|
|
||||||
|
|
||||||
`piker store shm FQME` is read-only by default. Persistence and reload enter
|
|
||||||
the prior repair workflow only through explicit `--write-parquet` and
|
|
||||||
`--reload-parquet-to-shm` flags. Reload requires persistence, and read-only
|
|
||||||
formatting flags are rejected in repair mode instead of being ignored.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/storage/cli.py`
|
|
||||||
|
|
||||||
Generated shared NativeDB payload collection, compact and detailed output,
|
|
||||||
numeric period ordering, default-all semantics, the canonical `shm` command,
|
|
||||||
safe mode dispatch, explicit repair validation, and debugger propagation in
|
|
||||||
read-only mode.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_store_cli.py`
|
|
||||||
|
|
||||||
Generated regressions for no-flag listing, parent-help descriptions, numeric
|
|
||||||
period display, default read-only SHM JSON, reload dependencies, and repair
|
|
||||||
versus read-only option conflicts.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_ldshm.py`
|
|
||||||
|
|
||||||
Adjusted persistence regressions for the renamed command and explicit repair
|
|
||||||
entry while preserving the historical failure rationale.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- docs/manual_backfill_qualification.rst`
|
|
||||||
|
|
||||||
Documented compact versus detailed NativeDB discovery, default read-only SHM
|
|
||||||
inspection, and the explicitly mutating repair invocation.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- snippets/claude_debug_helper.py`
|
|
||||||
|
|
||||||
Updated executable debug helper commands to select explicit SHM persistence.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- .claude/skills/run-tests/test-harness-reference.md`
|
|
||||||
|
|
||||||
Updated operational safety guidance and SHM regression mappings.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- .claude/skills/piker-slang/examples.md`
|
|
||||||
|
|
||||||
Updated the command example to the canonical `shm` spelling.
|
|
||||||
|
|
||||||
Verification output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
........................................................................ [ 69%]
|
|
||||||
................................ [100%]
|
|
||||||
104 passed in 5.77s
|
|
||||||
```
|
|
||||||
|
|
||||||
Real command-help rendering was checked for the parent group, `ls`, `series`,
|
|
||||||
and `shm`. Real read-only `ls` and `series --json` output was also exercised
|
|
||||||
against the configured NativeDB. `git diff --check` passed.
|
|
||||||
|
|
||||||
Adversarial review identified and drove fixes for lexicographic period
|
|
||||||
ordering, stale runbook safety wording, ignored mode-specific options, and an
|
|
||||||
explicit-default `--max-gaps 10` validation bypass. Final review found no
|
|
||||||
remaining issues. Real OS-backed SHM attachment and successful repair/reload
|
|
||||||
remain integration-level validation concerns.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: ses_0799212ebffe42arY96czXn89F
|
|
||||||
timestamp: 2026-08-05T19:50:01Z
|
|
||||||
git_ref: f0f3c806
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260805T195001Z_f0f3c806_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Inspect the completed NVDA 60s SHM backfill before workspace restart, explain
|
|
||||||
the persistent zero region, and continue fixing the indexing logic so sparse
|
|
||||||
IB history does not leave physical null reservations.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Captured a 42,720-row live SHM null span whose valid margins shared the same
|
|
||||||
timestamp. Replaced wall-clock row reservation with actual provider-boundary
|
|
||||||
placement, fixed stale SHM first-index, endpoint insertion and field-map
|
|
||||||
semantics, published the final storage prepend, and added a full multi-frame
|
|
||||||
`tsdb_backfill()` regression using a real `ShmArray`.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `piker/tsp/_history.py` - contiguous provider and NativeDB prepends.
|
|
||||||
- `tests/test_history_backfill.py` - sparse multi-frame SHM regression.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None - generated output incorporates the user's live observations.
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-08-05T19:50:01Z
|
|
||||||
git_ref: f0f3c806
|
|
||||||
diff_cmd: git diff HEAD~1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
The user reported that a completed NVDA 60s backfill left a large zero
|
|
||||||
region in live SHM and asked for immediate inspection before restarting the
|
|
||||||
workspace. The captured buffer contained 42,720 invalid rows at absolute
|
|
||||||
indexes `3065895..3108614`. The valid rows on both sides had the identical
|
|
||||||
timestamp `1781271360`, proving the region was an erroneous physical-index
|
|
||||||
reservation rather than a real temporal gap.
|
|
||||||
|
|
||||||
The user then asked the agent to continue fixing the backfill logic.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/tsp/_history.py`
|
|
||||||
|
|
||||||
Removed wall-clock-based SHM reservation from `tsdb_backfill()`. Reverse
|
|
||||||
provider frames now extend the visible `ShmArray._first` boundary on every
|
|
||||||
prepend, beginning exactly at the current first index after inclusive query
|
|
||||||
endpoints are removed. Added `_prepend_tsdb_history()` to trim storage rows
|
|
||||||
against the actual earliest provider timestamp and prepend them directly
|
|
||||||
adjacent only after reverse provider retrieval completes.
|
|
||||||
|
|
||||||
This preserves sparse venue history as ordinal samples rather than treating
|
|
||||||
every elapsed calendar period as a physical SHM row. It also prevents the
|
|
||||||
old `_first + 1` insertion point from overwriting the earliest published bar.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_history_backfill.py`
|
|
||||||
|
|
||||||
Added a full `tsdb_backfill()` regression modeled on the live NVDA failure.
|
|
||||||
It uses a real `ShmArray`, NativeDB's field map, two inclusive reverse-provider
|
|
||||||
frames, and overlapping stored history. Assertions prove the complete
|
|
||||||
timestamp sequence is contiguous and lossless, contains no non-positive
|
|
||||||
timestamps, sends complete provider frames to durable storage, and notifies
|
|
||||||
chart/FSP consumers after the final storage prepend.
|
|
||||||
|
|
||||||
Live evidence captured before workspace restart:
|
|
||||||
|
|
||||||
```text
|
|
||||||
rows 1575296 first_index 1568233 last_index 3143528
|
|
||||||
invalid_count 42720
|
|
||||||
left boundary
|
|
||||||
[(3065893, 1781271300, 204.03, 204.82)
|
|
||||||
(3065894, 1781271360, 204.81, 204.72)
|
|
||||||
(3065895, 0, 0. , 0. )]
|
|
||||||
right boundary
|
|
||||||
[(3108614, 0, 0. , 0. )
|
|
||||||
(3108615, 1781271360, 204.81, 205.44)
|
|
||||||
(3108616, 1781271420, 205.46, 205.48)]
|
|
||||||
```
|
|
||||||
|
|
||||||
Verification output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
........................................................................ [ 68%]
|
|
||||||
................................. [100%]
|
|
||||||
105 passed in 4.80s
|
|
||||||
```
|
|
||||||
|
|
||||||
Adversarial review caught stale `_first` semantics and an off-by-one
|
|
||||||
insertion point in the initial patch. The first live restart then exposed an
|
|
||||||
undefined `time_key` local at the new `tsdb_backfill()` call site, which
|
|
||||||
crashed `datad.ib` and propagated to the chart as `RemoteActorError`. The
|
|
||||||
helper now derives the source timestamp field from its field map, and the
|
|
||||||
test enters the full enclosing lifecycle so the original `NameError` can not
|
|
||||||
recur unnoticed. A final review also drove explicit publication after stored
|
|
||||||
history is prepended and deterministic final-stage synchronization. Final
|
|
||||||
review found no remaining issues; another live restart remains pending.
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
session: ses_0799212ebffe42arY96czXn89F
|
|
||||||
timestamp: 2026-08-11T20:58:26Z
|
|
||||||
git_ref: 06d5ea5d
|
|
||||||
scope: code
|
|
||||||
substantive: true
|
|
||||||
raw_file: 20260811T205826Z_06d5ea5d_prompt_io.raw.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
Diagnose and fix an IB live-order failure where lazy NVDA contract
|
|
||||||
qualification raised `tractor.trionics.Lagged` after the order reached
|
|
||||||
`brokerd.ib`. Determine whether recent history changes or cancelled symbol
|
|
||||||
searches caused the stale state.
|
|
||||||
|
|
||||||
## Response summary
|
|
||||||
|
|
||||||
Traced the failure to competing consumers of a one-slot Trio broadcast, not
|
|
||||||
the recent SHM change or stale asyncio client state. Replaced broadcast
|
|
||||||
fan-out with one response/status demultiplexer, retained request-ID
|
|
||||||
correlation, handled cancellation and teardown, documented the task/channel
|
|
||||||
ownership model, warned on diagnostic metadata eviction, and added
|
|
||||||
deterministic proxy regressions.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `piker/brokers/ib/api.py` - sole-reader IB proxy response routing.
|
|
||||||
- `tests/test_ib_method_proxy.py` - status, concurrency, cancellation and EOF regressions.
|
|
||||||
- `.claude/skills/run-tests/test-harness-reference.md` - proxy test mapping.
|
|
||||||
|
|
||||||
## Human edits
|
|
||||||
|
|
||||||
None - generated output follows the user's live failure diagnosis.
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
---
|
|
||||||
model: openai/gpt-5.6-sol
|
|
||||||
service: opencode
|
|
||||||
timestamp: 2026-08-11T20:58:26Z
|
|
||||||
git_ref: 06d5ea5d
|
|
||||||
diff_cmd: git diff HEAD~1..HEAD
|
|
||||||
---
|
|
||||||
|
|
||||||
The user reported that certain IB order entries might have broken and
|
|
||||||
provided a live failure for a first NVDA sell order. EMS delivered the
|
|
||||||
request to `brokerd.ib`, but lazy `get_mkt_info('nvda.nasdaq')` contract
|
|
||||||
qualification failed in `MethodProxy._run_method()` with:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tractor.trionics._broadcast.Lagged:
|
|
||||||
Task `piker.brokers.ib.broker.handle_order_requests` overrun and
|
|
||||||
dropped `0` values
|
|
||||||
```
|
|
||||||
|
|
||||||
The user suspected a cancelled symbol-search task might have left stale
|
|
||||||
asyncio state. Cancellation can leave an orphaned method response, but the
|
|
||||||
observed failure was stale Trio broadcast state. `open_client_proxy()`
|
|
||||||
broadcast a one-slot `LinkedTaskChannel` to an always-running IB status relay
|
|
||||||
and an idle method receiver. Status traffic advanced only the relay, so the
|
|
||||||
first method call for an uncached symbol entered an already-lagged receiver.
|
|
||||||
Orders for eagerly cached contracts bypassed this path.
|
|
||||||
|
|
||||||
The immediately preceding sparse-history commit did not modify IB order,
|
|
||||||
symbol, or proxy code; it exposed no direct dependency on this failure.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- piker/brokers/ib/api.py`
|
|
||||||
|
|
||||||
Generated a sole-reader message demultiplexer for each IB client proxy.
|
|
||||||
`relay_client_proxy_messages()` owns every asyncio-to-Trio receive, handles
|
|
||||||
status/error traffic, and dispatches `mid`-tagged responses into per-call
|
|
||||||
`MethodProxy` wait slots. Cancelled calls remove their pending slot, causing
|
|
||||||
late responses to be logged and dropped without skewing later calls. Clean
|
|
||||||
channel EOF is accepted during normal proxy teardown.
|
|
||||||
|
|
||||||
Follow-up review restored the masked reconnect TODO and per-message case
|
|
||||||
rationale, documented the one-reader/many-writer task hierarchy and separate
|
|
||||||
per-proxy channels, and added a warning with request method metadata when the
|
|
||||||
bounded unresolved-request diagnostic table evicts its oldest entry.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- tests/test_ib_method_proxy.py`
|
|
||||||
|
|
||||||
Generated deterministic regressions for idle status traffic before first
|
|
||||||
qualification, two concurrent calls with reverse-order responses, cancelled
|
|
||||||
calls with late responses followed by a successful call, and graceful
|
|
||||||
channel EOF. Every synchronization wait is timeout-bounded.
|
|
||||||
|
|
||||||
> `git diff HEAD~1..HEAD -- .claude/skills/run-tests/test-harness-reference.md`
|
|
||||||
|
|
||||||
Registered the deterministic IB method-proxy target and source mapping.
|
|
||||||
|
|
||||||
Verification output:
|
|
||||||
|
|
||||||
```text
|
|
||||||
........................ [100%]
|
|
||||||
25 passed, 4 warnings in 3.44s
|
|
||||||
```
|
|
||||||
|
|
||||||
The warnings are existing Tractor/Trio deprecations from the EMS test.
|
|
||||||
Adversarial review drove a clean-EOF guard and bounded polling. Final review
|
|
||||||
found no actionable issues. Live IB order validation remains pending and
|
|
||||||
requires restarting `brokerd.ib`.
|
|
||||||
|
|
@ -24,8 +24,8 @@ automatically corruption or an expected venue closure.
|
||||||
|
|
||||||
Do not run qualification against normal user storage. The helper below
|
Do not run qualification against normal user storage. The helper below
|
||||||
requires a marked disposable root before it will seed, archive, or clear
|
requires a marked disposable root before it will seed, archive, or clear
|
||||||
a Parquet. ``piker store anal`` and ``piker store shm --write-parquet``
|
a Parquet. ``piker store anal`` and ``piker store ldshm`` are interactive
|
||||||
are interactive and potentially mutating; they are not audit commands.
|
and potentially mutating; they are not audit commands.
|
||||||
|
|
||||||
Keep the disposable root private and do not rename, replace, or symlink
|
Keep the disposable root private and do not rename, replace, or symlink
|
||||||
files under it while a helper is running. Stop the chart cleanly before
|
files under it while a helper is running. Stop the chart cleanly before
|
||||||
|
|
@ -193,20 +193,6 @@ visible until a venue-aware classifier proves their session alignment.
|
||||||
Gap Layer Triage
|
Gap Layer Triage
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
List exact durable series before choosing a timeframe, and inspect live
|
|
||||||
SHM separately::
|
|
||||||
|
|
||||||
piker store ls
|
|
||||||
piker store series FQME_SUBSTRING
|
|
||||||
piker store shm FQME --max-gaps 20
|
|
||||||
|
|
||||||
All three forms are read-only. ``ls`` summarizes periods by FQME, while
|
|
||||||
``series`` reports each ``(FQME, period)`` file independently. Read-only
|
|
||||||
``shm`` reports every exact actor generation and
|
|
||||||
``hist``/``rt`` buffer for the FQME, including invalid rows, ordering
|
|
||||||
defects, inferred versus expected cadence, and the largest timestamp gaps.
|
|
||||||
Use ``--shm-name EXACT_NAME`` to inspect only one reported buffer.
|
|
||||||
|
|
||||||
Use the persisted report and chart together:
|
Use the persisted report and chart together:
|
||||||
|
|
||||||
=============================== =========================================
|
=============================== =========================================
|
||||||
|
|
|
||||||
|
|
@ -1643,61 +1643,6 @@ class MethodProxy:
|
||||||
# to the next caller causing an off-by-one skew of
|
# to the next caller causing an off-by-one skew of
|
||||||
# every result thereafter!
|
# every result thereafter!
|
||||||
self._mids = itertools.count()
|
self._mids = itertools.count()
|
||||||
self._pending: dict[
|
|
||||||
int,
|
|
||||||
tuple[trio.Event, list[dict]],
|
|
||||||
] = {}
|
|
||||||
self._request_methods: dict[int, str|None] = {}
|
|
||||||
|
|
||||||
def _deliver_method_response(
|
|
||||||
self,
|
|
||||||
msg: dict,
|
|
||||||
|
|
||||||
) -> bool:
|
|
||||||
'''
|
|
||||||
Deliver one correlated response to its waiting caller.
|
|
||||||
|
|
||||||
'''
|
|
||||||
mid: int = msg['mid']
|
|
||||||
meth: str|None = self._request_methods.pop(mid, None)
|
|
||||||
pending = self._pending.get(mid)
|
|
||||||
if pending is None:
|
|
||||||
log.warning(
|
|
||||||
f'Dropping stale method-resp,\n'
|
|
||||||
f'mid: {mid}\n'
|
|
||||||
f'meth: {meth!r}\n'
|
|
||||||
f'(caller prolly got cancelled '
|
|
||||||
f'before its resp?)\n'
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
event, slot = pending
|
|
||||||
slot.append(msg)
|
|
||||||
event.set()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _track_request_method(
|
|
||||||
self,
|
|
||||||
mid: int,
|
|
||||||
meth: str|None,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Retain bounded request metadata for stale-response logs.
|
|
||||||
|
|
||||||
'''
|
|
||||||
self._request_methods[mid] = meth
|
|
||||||
if len(self._request_methods) > 256:
|
|
||||||
oldest_mid: int = next(iter(self._request_methods))
|
|
||||||
oldest_meth: str|None = self._request_methods.pop(
|
|
||||||
oldest_mid
|
|
||||||
)
|
|
||||||
log.warning(
|
|
||||||
f'Evicting unresolved method-request metadata,\n'
|
|
||||||
f'mid: {oldest_mid}\n'
|
|
||||||
f'meth: {oldest_meth!r}\n'
|
|
||||||
f'pending metadata limit: 256\n'
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_method(
|
async def _run_method(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1711,30 +1656,65 @@ class MethodProxy:
|
||||||
``tractor.to_asyncio`` layer.
|
``tractor.to_asyncio`` layer.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
chan = self.chan
|
||||||
mid: int = next(self._mids)
|
mid: int = next(self._mids)
|
||||||
event = trio.Event()
|
await chan.send((meth, kwargs, mid))
|
||||||
slot: list[dict] = []
|
|
||||||
self._pending[mid] = event, slot
|
while not chan.closed():
|
||||||
self._track_request_method(mid, meth)
|
msg = await chan.receive()
|
||||||
try:
|
|
||||||
await self.chan.send((meth, kwargs, mid))
|
# TODO: implement reconnect functionality like
|
||||||
await event.wait()
|
# in our `.data._web_bs.NoBsWs`
|
||||||
finally:
|
# try:
|
||||||
self._pending.pop(mid, None)
|
# msg = await chan.receive()
|
||||||
|
# except ConnectionError:
|
||||||
|
# self.reset()
|
||||||
|
|
||||||
msg: dict = slot[0]
|
|
||||||
match msg:
|
match msg:
|
||||||
# OUR method-call response B)
|
# OUR method-call response B)
|
||||||
case {'result': res}:
|
case {'mid': resp_mid, 'result': res} if (
|
||||||
|
resp_mid == mid
|
||||||
|
):
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
case {'mid': resp_mid, 'exception': err} if (
|
||||||
|
resp_mid == mid
|
||||||
|
):
|
||||||
|
raise err
|
||||||
|
|
||||||
|
# an "orphaned" response to some prior
|
||||||
|
# (cancelled) caller's request; drop it and
|
||||||
|
# keep waiting for ours.
|
||||||
|
case {'mid': resp_mid}:
|
||||||
|
log.warning(
|
||||||
|
f'Dropping stale method-resp,\n'
|
||||||
|
f'mid: {resp_mid} (ours: {mid})\n'
|
||||||
|
f'(a prior caller prolly got '
|
||||||
|
f'cancelled before its resp?)\n'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# out-of-band (inline) client error: raise
|
||||||
|
# to the current caller as before.
|
||||||
case {'exception': err}:
|
case {'exception': err}:
|
||||||
raise err
|
raise err
|
||||||
|
|
||||||
case _:
|
case ('error', emsg):
|
||||||
raise RuntimeError(
|
log.warning(f'IB error relay: {emsg}')
|
||||||
f'Invalid method response for mid={mid}: {msg!r}'
|
continue
|
||||||
|
|
||||||
|
# routine (api-farm conn) status events
|
||||||
|
# relayed inline by `Client.inline_errors()`;
|
||||||
|
# not a response to our method call so just
|
||||||
|
# log at info and keep waiting.
|
||||||
|
case ('event', emsg):
|
||||||
|
log.info(
|
||||||
|
f'IB status event relay: {emsg}'
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
case _:
|
||||||
|
log.warning(f'UNKNOWN IB MSG: {msg}')
|
||||||
|
|
||||||
def status_event(
|
def status_event(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1820,73 +1800,6 @@ async def open_aio_client_method_relay(
|
||||||
raise ValueError(f'Unhandled msg {msg}')
|
raise ValueError(f'Unhandled msg {msg}')
|
||||||
|
|
||||||
|
|
||||||
async def relay_client_proxy_messages(
|
|
||||||
chan: tractor.to_asyncio.LinkedTaskChannel,
|
|
||||||
proxy: MethodProxy,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Route one proxy's asyncio-client msgs from a single reader.
|
|
||||||
|
|
||||||
Each `open_client_proxy()` allocates its own linked channel and
|
|
||||||
starts this task in its relay nursery. Any number of Trio caller
|
|
||||||
tasks may concurrently write tagged requests through
|
|
||||||
`MethodProxy._run_method()` and wait on their per-`mid` slots.
|
|
||||||
This task alone reads the channel and dispatches each response to
|
|
||||||
its waiting caller.
|
|
||||||
|
|
||||||
Separate proxy contexts own separate channels and reader tasks;
|
|
||||||
no cross-proxy response broadcast is intended. IB status events
|
|
||||||
still reach each proxy through its own `Client.inline_errors()`
|
|
||||||
handler.
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
while not chan.closed():
|
|
||||||
# TODO: implement reconnect functionality like
|
|
||||||
# in our `.data._web_bs.NoBsWs`
|
|
||||||
# try:
|
|
||||||
msg = await chan.receive()
|
|
||||||
# except ConnectionError:
|
|
||||||
# proxy.reset()
|
|
||||||
|
|
||||||
match msg:
|
|
||||||
# Correlated response from a method call.
|
|
||||||
case {'mid': _}:
|
|
||||||
proxy._deliver_method_response(msg)
|
|
||||||
|
|
||||||
# routine (api-farm conn) status events
|
|
||||||
# relayed inline by `Client.inline_errors()`;
|
|
||||||
# not a response to a method call so just
|
|
||||||
# log at info and keep waiting.
|
|
||||||
case ('event', status_msg):
|
|
||||||
reason = status_msg['reason']
|
|
||||||
event = proxy.event_table.pop(reason, None)
|
|
||||||
if (
|
|
||||||
event
|
|
||||||
and
|
|
||||||
event.statistics().tasks_waiting
|
|
||||||
):
|
|
||||||
log.info(f'Relaying ib status message: {msg}')
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
# Inline API error with no request correlation.
|
|
||||||
case ('error', emsg):
|
|
||||||
log.warning(f'IB error relay: {emsg}')
|
|
||||||
|
|
||||||
# Out-of-band client error with no method `mid`.
|
|
||||||
case {'exception': err}:
|
|
||||||
log.error(
|
|
||||||
f'Uncorrelated IB client error: {err!r}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Preserve visibility into unexpected relay traffic.
|
|
||||||
case _:
|
|
||||||
log.warning(f'UNKNOWN IB MSG: {msg}')
|
|
||||||
except trio.EndOfChannel:
|
|
||||||
log.info('IB client proxy relay closed')
|
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_client_proxy(
|
async def open_client_proxy(
|
||||||
client: Client,
|
client: Client,
|
||||||
|
|
@ -1923,11 +1836,28 @@ async def open_client_proxy(
|
||||||
continue
|
continue
|
||||||
setattr(proxy, name, partial(proxy._run_method, meth=name))
|
setattr(proxy, name, partial(proxy._run_method, meth=name))
|
||||||
|
|
||||||
relay_tn.start_soon(
|
async def relay_events():
|
||||||
relay_client_proxy_messages,
|
|
||||||
chan,
|
async with chan.subscribe() as msg_stream:
|
||||||
proxy,
|
|
||||||
)
|
async for msg in msg_stream:
|
||||||
|
if 'event' not in msg:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# if 'event' in msg:
|
||||||
|
# wake up any system event waiters.
|
||||||
|
etype, status_msg = msg
|
||||||
|
reason = status_msg['reason']
|
||||||
|
|
||||||
|
ev = proxy.event_table.pop(reason, None)
|
||||||
|
|
||||||
|
if ev and ev.statistics().tasks_waiting:
|
||||||
|
log.info(f'Relaying ib status message: {msg}')
|
||||||
|
ev.set()
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
relay_tn.start_soon(relay_events)
|
||||||
|
|
||||||
yield proxy
|
yield proxy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,28 +63,6 @@ def _make_token(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def attach_existing_shm_array(
|
|
||||||
key: str,
|
|
||||||
size: int,
|
|
||||||
dtype: np.dtype|None = None,
|
|
||||||
readonly: bool = True,
|
|
||||||
|
|
||||||
) -> ShmArray:
|
|
||||||
'''
|
|
||||||
Attach to an existing segment without creating one on races.
|
|
||||||
|
|
||||||
'''
|
|
||||||
token: NDToken = _known_tokens.get(key) or _make_token(
|
|
||||||
key,
|
|
||||||
size=size,
|
|
||||||
dtype=dtype,
|
|
||||||
)
|
|
||||||
return attach_shm_ndarray(
|
|
||||||
token=token,
|
|
||||||
readonly=readonly,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def maybe_open_shm_array(
|
def maybe_open_shm_array(
|
||||||
key: str,
|
key: str,
|
||||||
size: int,
|
size: int,
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,12 @@ Storage middle-ware CLIs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import (
|
|
||||||
UTC,
|
|
||||||
datetime,
|
|
||||||
)
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Annotated,
|
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,43 +46,18 @@ from piker import tsp
|
||||||
from piker import config
|
from piker import config
|
||||||
from . import log
|
from . import log
|
||||||
from . import (
|
from . import (
|
||||||
|
__tsdbs__,
|
||||||
open_storage_client,
|
open_storage_client,
|
||||||
StorageClient,
|
StorageClient,
|
||||||
)
|
)
|
||||||
from ._audit import audit_ohlcv_parquet
|
from ._audit import audit_ohlcv_parquet
|
||||||
from .nativedb import (
|
from .nativedb import mk_ohlcv_shm_keyed_filepath
|
||||||
iter_native_series,
|
|
||||||
mk_ohlcv_shm_keyed_filepath,
|
|
||||||
NativeSeriesRef,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from piker.ui._remote_ctl import AnnotCtl
|
from piker.ui._remote_ctl import AnnotCtl
|
||||||
|
|
||||||
|
|
||||||
store = typer.Typer(no_args_is_help=True)
|
store = typer.Typer()
|
||||||
_shm_periods: dict[str, int] = {
|
|
||||||
'hist': 60,
|
|
||||||
'rt': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _format_utc(timestamp: float) -> str|None:
|
|
||||||
'''
|
|
||||||
Format a timestamp without aborting on corrupt epochs.
|
|
||||||
|
|
||||||
'''
|
|
||||||
try:
|
|
||||||
return datetime.fromtimestamp(
|
|
||||||
timestamp,
|
|
||||||
tz=UTC,
|
|
||||||
).isoformat()
|
|
||||||
except (
|
|
||||||
OSError,
|
|
||||||
OverflowError,
|
|
||||||
ValueError,
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _shm_period_and_invalid_count(
|
def _shm_period_and_invalid_count(
|
||||||
|
|
@ -121,95 +91,6 @@ def _shm_period_and_invalid_count(
|
||||||
return period, invalid_count
|
return period, invalid_count
|
||||||
|
|
||||||
|
|
||||||
def _summarize_shm_frame(
|
|
||||||
ref: tsp.ShmBufferRef,
|
|
||||||
frame: np.ndarray,
|
|
||||||
max_gaps: int,
|
|
||||||
|
|
||||||
) -> dict:
|
|
||||||
'''
|
|
||||||
Summarize one immutable OHLCV SHM snapshot.
|
|
||||||
|
|
||||||
'''
|
|
||||||
times: np.ndarray = frame['time']
|
|
||||||
observed_period_s, invalid_count = (
|
|
||||||
_shm_period_and_invalid_count(times)
|
|
||||||
)
|
|
||||||
period_s: int = _shm_periods[ref.kind]
|
|
||||||
valid: np.ndarray = (
|
|
||||||
np.isfinite(times)
|
|
||||||
&
|
|
||||||
(times > 0)
|
|
||||||
)
|
|
||||||
published: np.ndarray = times[valid]
|
|
||||||
steps: np.ndarray = np.diff(published)
|
|
||||||
duplicate_steps: int = int(np.count_nonzero(steps == 0))
|
|
||||||
reversed_steps: int = int(np.count_nonzero(steps < 0))
|
|
||||||
gaps: list[dict] = []
|
|
||||||
if published.size > 1:
|
|
||||||
indexes: np.ndarray = np.flatnonzero(steps > period_s)
|
|
||||||
ranked: list[int] = sorted(
|
|
||||||
indexes.tolist(),
|
|
||||||
key=lambda index: steps[index],
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
for index in ranked[:max_gaps]:
|
|
||||||
left: float = float(published[index])
|
|
||||||
right: float = float(published[index + 1])
|
|
||||||
gaps.append({
|
|
||||||
'delta_s': float(steps[index]),
|
|
||||||
'left_timestamp': left,
|
|
||||||
'left_utc': _format_utc(left),
|
|
||||||
'right_timestamp': right,
|
|
||||||
'right_utc': _format_utc(right),
|
|
||||||
})
|
|
||||||
|
|
||||||
invalid: np.ndarray = np.flatnonzero(~valid)
|
|
||||||
return {
|
|
||||||
'shm_name': ref.path.name,
|
|
||||||
'service': ref.service,
|
|
||||||
'generation': ref.generation,
|
|
||||||
'fqme': ref.fqme,
|
|
||||||
'kind': ref.kind,
|
|
||||||
'rows': len(frame),
|
|
||||||
'first_index': (
|
|
||||||
int(frame['index'][0])
|
|
||||||
if len(frame)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'last_index': (
|
|
||||||
int(frame['index'][-1])
|
|
||||||
if len(frame)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'first_timestamp': (
|
|
||||||
float(published[0])
|
|
||||||
if published.size
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'last_timestamp': (
|
|
||||||
float(published[-1])
|
|
||||||
if published.size
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'period_s': period_s,
|
|
||||||
'observed_period_s': observed_period_s,
|
|
||||||
'invalid_count': invalid_count,
|
|
||||||
'duplicate_step_count': duplicate_steps,
|
|
||||||
'reversed_step_count': reversed_steps,
|
|
||||||
'invalid_index_bounds': (
|
|
||||||
[
|
|
||||||
int(frame['index'][invalid[0]]),
|
|
||||||
int(frame['index'][invalid[-1]]),
|
|
||||||
]
|
|
||||||
if invalid.size
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
'gap_count': len(np.flatnonzero(steps > period_s)),
|
|
||||||
'largest_gaps': gaps,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _render_audit_report(report: dict) -> None:
|
def _render_audit_report(report: dict) -> None:
|
||||||
'''
|
'''
|
||||||
Render a compact human summary and explicit gap endpoints.
|
Render a compact human summary and explicit gap endpoints.
|
||||||
|
|
@ -269,146 +150,48 @@ def _render_audit_report(report: dict) -> None:
|
||||||
console.print(gap_table)
|
console.print(gap_table)
|
||||||
|
|
||||||
|
|
||||||
def _native_series_payload(
|
|
||||||
pattern: str|None,
|
|
||||||
|
|
||||||
) -> tuple[Path, list[dict]]:
|
|
||||||
'''
|
|
||||||
Collect exact NativeDB series metadata for CLI presentation.
|
|
||||||
|
|
||||||
'''
|
|
||||||
datadir: Path = config.get_conf_dir() / 'nativedb'
|
|
||||||
refs: list[NativeSeriesRef] = list(iter_native_series(datadir))
|
|
||||||
if pattern is not None:
|
|
||||||
needle: str = pattern.casefold()
|
|
||||||
refs = [
|
|
||||||
ref
|
|
||||||
for ref in refs
|
|
||||||
if needle in ref.fqme.casefold()
|
|
||||||
]
|
|
||||||
|
|
||||||
payload: list[dict] = []
|
|
||||||
for ref in refs:
|
|
||||||
try:
|
|
||||||
size_bytes: int = ref.path.stat().st_size
|
|
||||||
except FileNotFoundError:
|
|
||||||
continue
|
|
||||||
payload.append({
|
|
||||||
'fqme': ref.fqme,
|
|
||||||
'period_s': ref.period_s,
|
|
||||||
'path': str(ref.path),
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
})
|
|
||||||
payload.sort(key=lambda item: (
|
|
||||||
item['fqme'],
|
|
||||||
item['period_s'],
|
|
||||||
))
|
|
||||||
|
|
||||||
return datadir, payload
|
|
||||||
|
|
||||||
|
|
||||||
@store.command()
|
@store.command()
|
||||||
def ls(
|
def ls(
|
||||||
pattern: Annotated[
|
backends: list[str] = typer.Argument(
|
||||||
str|None,
|
default=None,
|
||||||
typer.Argument(
|
help='Storage backends to query, default is all.'
|
||||||
help='Optional case-insensitive FQME substring.',
|
|
||||||
),
|
),
|
||||||
] = None,
|
|
||||||
all_series: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--all',
|
|
||||||
help='List all FQMEs (the default).',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
List NativeDB FQMEs and their available sample periods.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if (
|
|
||||||
all_series
|
|
||||||
and
|
|
||||||
pattern is not None
|
|
||||||
):
|
):
|
||||||
raise typer.BadParameter('Pass a pattern or --all, not both')
|
from rich.table import Table
|
||||||
|
|
||||||
datadir, payload = _native_series_payload(pattern)
|
if not backends:
|
||||||
periods_by_fqme: dict[str, list[int]] = {}
|
backends: list[str] = __tsdbs__
|
||||||
for item in payload:
|
|
||||||
periods_by_fqme.setdefault(item['fqme'], []).append(
|
|
||||||
item['period_s']
|
|
||||||
)
|
|
||||||
|
|
||||||
table = Table(title=f'NativeDB series @ {datadir}')
|
console = Console()
|
||||||
table.add_column('FQME')
|
|
||||||
table.add_column('Periods')
|
async def query_all():
|
||||||
for fqme, periods in periods_by_fqme.items():
|
nonlocal backends
|
||||||
table.add_row(
|
|
||||||
fqme,
|
async with (
|
||||||
', '.join(
|
open_piker_runtime(
|
||||||
f'{period}s'
|
'tsdb_storage',
|
||||||
for period in sorted(periods)
|
|
||||||
),
|
),
|
||||||
)
|
):
|
||||||
Console().print(table)
|
for i, backend in enumerate(backends):
|
||||||
|
table = Table()
|
||||||
|
try:
|
||||||
|
async with open_storage_client(backend=backend) as (
|
||||||
|
mod,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
table.add_column(f'{mod.name}@{client.address}')
|
||||||
|
keys: list[str] = await client.list_keys()
|
||||||
|
for key in keys:
|
||||||
|
table.add_row(key)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
|
except Exception:
|
||||||
|
log.error(f'Unable to connect to storage engine: `{backend}`')
|
||||||
|
|
||||||
|
trio.run(query_all)
|
||||||
|
|
||||||
|
|
||||||
@store.command()
|
@store.command()
|
||||||
def series(
|
|
||||||
pattern: Annotated[
|
|
||||||
str|None,
|
|
||||||
typer.Argument(
|
|
||||||
help='Optional case-insensitive FQME substring.',
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
all_series: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--all',
|
|
||||||
help='List all series (the default).',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
json_output: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--json',
|
|
||||||
help='Emit machine-readable JSON.',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
List detailed NativeDB sub-series without opening a runtime.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if (
|
|
||||||
all_series
|
|
||||||
and
|
|
||||||
pattern is not None
|
|
||||||
):
|
|
||||||
raise typer.BadParameter('Pass a pattern or --all, not both')
|
|
||||||
datadir, payload = _native_series_payload(pattern)
|
|
||||||
if json_output:
|
|
||||||
typer.echo(json.dumps(payload, indent=2, sort_keys=True))
|
|
||||||
return
|
|
||||||
|
|
||||||
table = Table(title=f'NativeDB series @ {datadir}')
|
|
||||||
table.add_column('FQME')
|
|
||||||
table.add_column('Period')
|
|
||||||
table.add_column('Bytes', justify='right')
|
|
||||||
table.add_column('Path')
|
|
||||||
for item in payload:
|
|
||||||
table.add_row(
|
|
||||||
item['fqme'],
|
|
||||||
f'{item["period_s"]}s',
|
|
||||||
str(item['size_bytes']),
|
|
||||||
item['path'],
|
|
||||||
)
|
|
||||||
Console().print(table)
|
|
||||||
|
|
||||||
|
|
||||||
@store.command(no_args_is_help=True)
|
|
||||||
def audit(
|
def audit(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: int = typer.Option(
|
period: int = typer.Option(
|
||||||
|
|
@ -584,7 +367,7 @@ def audit(
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
|
|
||||||
@store.command(no_args_is_help=True)
|
@store.command()
|
||||||
def delete(
|
def delete(
|
||||||
symbols: list[str],
|
symbols: list[str],
|
||||||
|
|
||||||
|
|
@ -622,7 +405,7 @@ def delete(
|
||||||
trio.run(main, symbols)
|
trio.run(main, symbols)
|
||||||
|
|
||||||
|
|
||||||
@store.command(no_args_is_help=True)
|
@store.command()
|
||||||
def anal(
|
def anal(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
period: int = 60,
|
period: int = 60,
|
||||||
|
|
@ -711,234 +494,21 @@ def anal(
|
||||||
trio.run(main)
|
trio.run(main)
|
||||||
|
|
||||||
|
|
||||||
def _inspect_shm(
|
@store.command()
|
||||||
|
def ldshm(
|
||||||
fqme: str,
|
fqme: str,
|
||||||
shm_name: Annotated[
|
write_parquet: bool = True,
|
||||||
str|None,
|
reload_parquet_to_shm: bool = True,
|
||||||
typer.Option(
|
|
||||||
'--shm-name',
|
|
||||||
help='Inspect only this exact SHM object name.',
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
max_gaps: Annotated[
|
|
||||||
int,
|
|
||||||
typer.Option(
|
|
||||||
'--max-gaps',
|
|
||||||
min=0,
|
|
||||||
help='Maximum largest gaps to show per buffer.',
|
|
||||||
),
|
|
||||||
] = 10,
|
|
||||||
json_output: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--json',
|
|
||||||
help='Emit machine-readable JSON.',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
pdb: bool = False,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Inspect matching OHLCV SHM buffers without mutating them.
|
|
||||||
|
|
||||||
'''
|
|
||||||
refs: list[tsp.ShmBufferRef] = [
|
|
||||||
ref
|
|
||||||
for ref in tsp.iter_shm_buffer_refs(fqme=fqme)
|
|
||||||
if (
|
|
||||||
shm_name is None
|
|
||||||
or
|
|
||||||
ref.path.name == shm_name
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if not refs:
|
|
||||||
typer.echo(
|
|
||||||
f'No exact OHLCV SHM buffers found for {fqme!r}',
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
raise typer.Exit(code=2)
|
|
||||||
|
|
||||||
ref_by_name: dict[str, tsp.ShmBufferRef] = {
|
|
||||||
ref.path.name: ref
|
|
||||||
for ref in refs
|
|
||||||
}
|
|
||||||
reports: list[dict] = []
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
async with open_piker_runtime(
|
|
||||||
'shm_inspector',
|
|
||||||
debug_mode=pdb,
|
|
||||||
):
|
|
||||||
for (
|
|
||||||
shmfile,
|
|
||||||
shm,
|
|
||||||
_shm_df,
|
|
||||||
) in tsp.iter_dfs_from_shms(
|
|
||||||
fqme,
|
|
||||||
shm_name=shm_name,
|
|
||||||
refs=refs,
|
|
||||||
):
|
|
||||||
ref: tsp.ShmBufferRef = ref_by_name[shmfile.name]
|
|
||||||
reports.append(_summarize_shm_frame(
|
|
||||||
ref,
|
|
||||||
shm.array.copy(),
|
|
||||||
max_gaps,
|
|
||||||
))
|
|
||||||
|
|
||||||
trio.run(main)
|
|
||||||
if not reports:
|
|
||||||
typer.echo(
|
|
||||||
'Selected SHM buffers disappeared before inspection',
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
raise typer.Exit(code=2)
|
|
||||||
if json_output:
|
|
||||||
typer.echo(json.dumps(reports, indent=2, sort_keys=True))
|
|
||||||
return
|
|
||||||
|
|
||||||
table = Table(title=f'OHLCV SHM buffers for {fqme}')
|
|
||||||
table.add_column('Kind')
|
|
||||||
table.add_column('Generation')
|
|
||||||
table.add_column('Rows', justify='right')
|
|
||||||
table.add_column('Period')
|
|
||||||
table.add_column('Invalid', justify='right')
|
|
||||||
table.add_column('Order', justify='right')
|
|
||||||
table.add_column('Gaps', justify='right')
|
|
||||||
table.add_column('SHM name')
|
|
||||||
for report in reports:
|
|
||||||
period_s: int = report['period_s']
|
|
||||||
table.add_row(
|
|
||||||
report['kind'],
|
|
||||||
report['generation'],
|
|
||||||
str(report['rows']),
|
|
||||||
f'{period_s}s',
|
|
||||||
str(report['invalid_count']),
|
|
||||||
str(
|
|
||||||
report['duplicate_step_count']
|
|
||||||
+
|
|
||||||
report['reversed_step_count']
|
|
||||||
),
|
|
||||||
str(report['gap_count']),
|
|
||||||
report['shm_name'],
|
|
||||||
)
|
|
||||||
Console().print(table)
|
|
||||||
|
|
||||||
for report in reports:
|
|
||||||
gaps: list[dict] = report['largest_gaps']
|
|
||||||
if not gaps:
|
|
||||||
continue
|
|
||||||
gap_table = Table(
|
|
||||||
title=f'Largest gaps: {report["shm_name"]}'
|
|
||||||
)
|
|
||||||
gap_table.add_column('Delta (s)')
|
|
||||||
gap_table.add_column('Left UTC')
|
|
||||||
gap_table.add_column('Right UTC')
|
|
||||||
for gap in gaps:
|
|
||||||
gap_table.add_row(
|
|
||||||
str(gap['delta_s']),
|
|
||||||
str(gap['left_utc']),
|
|
||||||
str(gap['right_utc']),
|
|
||||||
)
|
|
||||||
Console().print(gap_table)
|
|
||||||
|
|
||||||
|
|
||||||
@store.command(no_args_is_help=True)
|
|
||||||
def shm(
|
|
||||||
fqme: str,
|
|
||||||
write_parquet: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--write-parquet',
|
|
||||||
help='Persist repaired frames to NativeDB.',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
reload_parquet_to_shm: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--reload-parquet-to-shm',
|
|
||||||
help='Reload persisted repairs into SHM.',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
pdb: bool = False, # --pdb passed?
|
pdb: bool = False, # --pdb passed?
|
||||||
max_gaps: Annotated[
|
|
||||||
int|None,
|
|
||||||
typer.Option(
|
|
||||||
'--max-gaps',
|
|
||||||
min=0,
|
|
||||||
help=(
|
|
||||||
'Maximum largest gaps in read-only output '
|
|
||||||
'(default: 10).'
|
|
||||||
),
|
|
||||||
show_default=False,
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
json_output: Annotated[
|
|
||||||
bool,
|
|
||||||
typer.Option(
|
|
||||||
'--json',
|
|
||||||
help='Emit read-only output as JSON.',
|
|
||||||
),
|
|
||||||
] = False,
|
|
||||||
shm_name: Annotated[
|
|
||||||
str|None,
|
|
||||||
typer.Option(
|
|
||||||
'--shm-name',
|
|
||||||
help='Select only this exact SHM object name.',
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
Inspect volatile OHLCV SHM, with explicit repair options.
|
Linux ONLY: load any fqme file name matching shm buffer from
|
||||||
|
/dev/shm/ into an OHLCV numpy array and polars DataFrame,
|
||||||
|
optionally write to offline storage via `.parquet` file.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if (
|
async def main():
|
||||||
reload_parquet_to_shm
|
|
||||||
and
|
|
||||||
not write_parquet
|
|
||||||
):
|
|
||||||
raise typer.BadParameter(
|
|
||||||
'--reload-parquet-to-shm requires --write-parquet'
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
write_parquet
|
|
||||||
and (
|
|
||||||
json_output
|
|
||||||
or
|
|
||||||
max_gaps is not None
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise typer.BadParameter(
|
|
||||||
'--json and --max-gaps apply only to read-only output'
|
|
||||||
)
|
|
||||||
if not write_parquet:
|
|
||||||
_inspect_shm(
|
|
||||||
fqme,
|
|
||||||
shm_name=shm_name,
|
|
||||||
max_gaps=(
|
|
||||||
10
|
|
||||||
if max_gaps is None
|
|
||||||
else max_gaps
|
|
||||||
),
|
|
||||||
json_output=json_output,
|
|
||||||
pdb=pdb,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
shm_name is not None
|
|
||||||
and
|
|
||||||
not any(
|
|
||||||
ref.path.name == shm_name
|
|
||||||
for ref in tsp.iter_shm_buffer_refs(fqme=fqme)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
typer.echo(
|
|
||||||
f'No exact OHLCV SHM buffer named {shm_name!r}',
|
|
||||||
err=True,
|
|
||||||
)
|
|
||||||
raise typer.Exit(code=2)
|
|
||||||
|
|
||||||
async def main() -> bool:
|
|
||||||
from piker.ui._remote_ctl import (
|
from piker.ui._remote_ctl import (
|
||||||
open_annot_ctl,
|
open_annot_ctl,
|
||||||
)
|
)
|
||||||
|
|
@ -960,35 +530,24 @@ def shm(
|
||||||
shm_df: pl.DataFrame | None = None
|
shm_df: pl.DataFrame | None = None
|
||||||
tf2aids: dict[float, dict] = {}
|
tf2aids: dict[float, dict] = {}
|
||||||
|
|
||||||
iter_kwargs: dict = {}
|
|
||||||
if shm_name is not None:
|
|
||||||
iter_kwargs['shm_name'] = shm_name
|
|
||||||
for (
|
for (
|
||||||
shmfile,
|
shmfile,
|
||||||
shm,
|
shm,
|
||||||
# parquet_path,
|
# parquet_path,
|
||||||
shm_df,
|
shm_df,
|
||||||
) in tsp.iter_dfs_from_shms(fqme, **iter_kwargs):
|
) in tsp.iter_dfs_from_shms(fqme):
|
||||||
|
|
||||||
times: np.ndarray = shm_df['time'].to_numpy()
|
times: np.ndarray = shm_df['time'].to_numpy()
|
||||||
(
|
(
|
||||||
observed_period_s,
|
period_s,
|
||||||
invalid_count,
|
invalid_count,
|
||||||
) = _shm_period_and_invalid_count(times)
|
) = _shm_period_and_invalid_count(times)
|
||||||
if observed_period_s is None:
|
if period_s is None:
|
||||||
log.warning(
|
log.warning(
|
||||||
f'Could not infer a positive sample period '
|
f'Could not infer a positive sample period '
|
||||||
f'for {shmfile.name}; skipping buffer\n'
|
f'for {shmfile.name}; skipping buffer\n'
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
kind: str = shmfile.suffix.removeprefix('.')
|
|
||||||
period_s: int = _shm_periods[kind]
|
|
||||||
if observed_period_s != period_s:
|
|
||||||
log.warning(
|
|
||||||
f'Modal step {observed_period_s}s in '
|
|
||||||
f'{period_s}s {shmfile.name}; using kind '
|
|
||||||
f'for storage identity\n'
|
|
||||||
)
|
|
||||||
log.info(
|
log.info(
|
||||||
f'Processing shm buffer:\n'
|
f'Processing shm buffer:\n'
|
||||||
f' file: {shmfile.name}\n'
|
f' file: {shmfile.name}\n'
|
||||||
|
|
@ -1137,11 +696,12 @@ def shm(
|
||||||
|
|
||||||
do_markup_gaps: bool = True
|
do_markup_gaps: bool = True
|
||||||
if do_markup_gaps:
|
if do_markup_gaps:
|
||||||
|
new_df: pl.DataFrame = tsp.np2pl(new)
|
||||||
aids: dict = await tsp._annotate.markup_gaps(
|
aids: dict = await tsp._annotate.markup_gaps(
|
||||||
fqme,
|
fqme,
|
||||||
period_s,
|
period_s,
|
||||||
actl,
|
actl,
|
||||||
deduped,
|
new_df,
|
||||||
step_gaps,
|
step_gaps,
|
||||||
)
|
)
|
||||||
# last chance manual overwrites in REPL
|
# last chance manual overwrites in REPL
|
||||||
|
|
@ -1164,19 +724,16 @@ def shm(
|
||||||
'but no significant time gaps!\n'
|
'but no significant time gaps!\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await tractor.pause()
|
||||||
|
log.info('Exiting TSP shm anal-izer!')
|
||||||
|
|
||||||
if shm_df is None:
|
if shm_df is None:
|
||||||
log.error(
|
log.error(
|
||||||
f'No matching shm buffers for {fqme} ?'
|
f'No matching shm buffers for {fqme} ?'
|
||||||
|
|
||||||
)
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
await tractor.pause()
|
trio.run(main)
|
||||||
log.info('Exiting TSP shm anal-izer!')
|
|
||||||
return True
|
|
||||||
|
|
||||||
if not trio.run(main):
|
|
||||||
raise typer.Exit(code=2)
|
|
||||||
|
|
||||||
|
|
||||||
typer_click_object = typer.main.get_command(store)
|
typer_click_object = typer.main.get_command(store)
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,7 @@ YET!
|
||||||
# - https://github.com/spslater/borgapi
|
# - https://github.com/spslater/borgapi
|
||||||
# - https://nixos.wiki/wiki/ZFS
|
# - https://nixos.wiki/wiki/ZFS
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections.abc import Iterator
|
|
||||||
from contextlib import asynccontextmanager as acm
|
from contextlib import asynccontextmanager as acm
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -84,17 +82,6 @@ _price_fields: tuple[str, ...] = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class NativeSeriesRef:
|
|
||||||
'''
|
|
||||||
Identify one canonical NativeDB OHLCV file.
|
|
||||||
|
|
||||||
'''
|
|
||||||
fqme: str
|
|
||||||
period_s: int
|
|
||||||
path: Path
|
|
||||||
|
|
||||||
|
|
||||||
def detect_period(shm: ShmArray) -> float:
|
def detect_period(shm: ShmArray) -> float:
|
||||||
'''
|
'''
|
||||||
Attempt to detect the series time step sampling period
|
Attempt to detect the series time step sampling period
|
||||||
|
|
@ -120,14 +107,8 @@ def mk_ohlcv_shm_keyed_filepath(
|
||||||
|
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
|
||||||
if (
|
if period < 1.:
|
||||||
period < 1.
|
raise ValueError('Sample period should be >= 1.!?')
|
||||||
or
|
|
||||||
int(period) != period
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
'Sample period must be positive whole seconds'
|
|
||||||
)
|
|
||||||
|
|
||||||
path: Path = (
|
path: Path = (
|
||||||
datadir
|
datadir
|
||||||
|
|
@ -137,98 +118,12 @@ def mk_ohlcv_shm_keyed_filepath(
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def parse_ohlcv_parquet_path(
|
|
||||||
path: Path,
|
|
||||||
|
|
||||||
) -> NativeSeriesRef|None:
|
|
||||||
'''
|
|
||||||
Parse one canonical NativeDB OHLCV filename.
|
|
||||||
|
|
||||||
'''
|
|
||||||
name: str = path.name
|
|
||||||
if not name.endswith('.parquet'):
|
|
||||||
return None
|
|
||||||
|
|
||||||
stem: str = name.removesuffix('.parquet')
|
|
||||||
fqme, marker, period_text = stem.rpartition('.ohlcv')
|
|
||||||
if (
|
|
||||||
marker != '.ohlcv'
|
|
||||||
or
|
|
||||||
not fqme
|
|
||||||
or
|
|
||||||
not period_text.endswith('s')
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
digits: str = period_text.removesuffix('s')
|
|
||||||
if (
|
|
||||||
not digits.isascii()
|
|
||||||
or
|
|
||||||
not digits.isdecimal()
|
|
||||||
or
|
|
||||||
digits.startswith('0')
|
|
||||||
or
|
|
||||||
'..' in path.parts
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
period_s: int = int(digits)
|
|
||||||
if (
|
|
||||||
fqme in {'.', '..'}
|
|
||||||
or
|
|
||||||
Path(fqme).name != fqme
|
|
||||||
or
|
|
||||||
'/' in fqme
|
|
||||||
or
|
|
||||||
'\\' in fqme
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
expected: Path = mk_ohlcv_shm_keyed_filepath(
|
|
||||||
fqme,
|
|
||||||
period_s,
|
|
||||||
path.parent,
|
|
||||||
)
|
|
||||||
if expected.name != name:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return NativeSeriesRef(
|
|
||||||
fqme=fqme,
|
|
||||||
period_s=period_s,
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def iter_native_series(
|
|
||||||
datadir: Path,
|
|
||||||
) -> Iterator[NativeSeriesRef]:
|
|
||||||
'''
|
|
||||||
Yield canonical NativeDB series in deterministic order.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not datadir.is_dir():
|
|
||||||
return
|
|
||||||
|
|
||||||
for path in sorted(
|
|
||||||
datadir.iterdir(),
|
|
||||||
key=lambda candidate: candidate.name,
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
path.is_symlink()
|
|
||||||
or
|
|
||||||
not path.is_file()
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
ref: NativeSeriesRef|None = parse_ohlcv_parquet_path(path)
|
|
||||||
if ref is not None:
|
|
||||||
yield ref
|
|
||||||
|
|
||||||
|
|
||||||
def unpack_fqme_from_parquet_filepath(path: Path) -> str:
|
def unpack_fqme_from_parquet_filepath(path: Path) -> str:
|
||||||
ref: NativeSeriesRef|None = parse_ohlcv_parquet_path(path)
|
|
||||||
if ref is None:
|
filename: str = str(path.name)
|
||||||
raise ValueError(f'Invalid NativeDB OHLCV path: {path}')
|
fqme, fmt_descr, suffix = filename.split('.')
|
||||||
return ref.fqme
|
assert suffix == 'parquet'
|
||||||
|
return fqme
|
||||||
|
|
||||||
|
|
||||||
# Only hydrate fields shared by canonical storage and provider SHM.
|
# Only hydrate fields shared by canonical storage and provider SHM.
|
||||||
|
|
@ -257,7 +152,7 @@ class NativeStorageClient:
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._datadir = datadir
|
self._datadir = datadir
|
||||||
self._index: dict[tuple[str, int], NativeSeriesRef] = {}
|
self._index: dict[str, dict] = {}
|
||||||
|
|
||||||
# series' cache from tsdb reads
|
# series' cache from tsdb reads
|
||||||
self._dfs: dict[str, dict[str, pl.DataFrame]] = {}
|
self._dfs: dict[str, dict[str, pl.DataFrame]] = {}
|
||||||
|
|
@ -268,10 +163,6 @@ class NativeStorageClient:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cardinality(self) -> int:
|
def cardinality(self) -> int:
|
||||||
return len({fqme for fqme, _ in self._index})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def series_cardinality(self) -> int:
|
|
||||||
return len(self._index)
|
return len(self._index)
|
||||||
|
|
||||||
# @property
|
# @property
|
||||||
|
|
@ -279,15 +170,29 @@ class NativeStorageClient:
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
async def list_keys(self) -> list[str]:
|
async def list_keys(self) -> list[str]:
|
||||||
return sorted({fqme for fqme, _ in self._index})
|
return list(self._index)
|
||||||
|
|
||||||
async def list_series(self) -> list[NativeSeriesRef]:
|
|
||||||
return list(self._index.values())
|
|
||||||
|
|
||||||
def index_files(self):
|
def index_files(self):
|
||||||
self._index.clear()
|
for path in self._datadir.iterdir():
|
||||||
for ref in iter_native_series(self._datadir):
|
if (
|
||||||
self._index[(ref.fqme, ref.period_s)] = ref
|
path.is_dir()
|
||||||
|
or
|
||||||
|
path.suffix != '.parquet'
|
||||||
|
# or
|
||||||
|
# path.name in {'borked', 'expired',}
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
key: str = path.name.removesuffix('.parquet')
|
||||||
|
fqme, _, descr = key.rpartition('.')
|
||||||
|
prefix, _, suffix = descr.partition('ohlcv')
|
||||||
|
period: int = int(suffix.strip('s'))
|
||||||
|
|
||||||
|
# cache description data
|
||||||
|
self._index[fqme] = {
|
||||||
|
'path': path,
|
||||||
|
'period': period,
|
||||||
|
}
|
||||||
|
|
||||||
return self._index
|
return self._index
|
||||||
|
|
||||||
|
|
@ -322,11 +227,11 @@ class NativeStorageClient:
|
||||||
bs_fqme, _, *_ = fqme.rpartition('.')
|
bs_fqme, _, *_ = fqme.rpartition('.')
|
||||||
|
|
||||||
possible_matches: list[str] = []
|
possible_matches: list[str] = []
|
||||||
for tsfqme, _period in self._index:
|
for tskey in self._index:
|
||||||
if bs_fqme in tsfqme:
|
if bs_fqme in tskey:
|
||||||
possible_matches.append(tsfqme)
|
possible_matches.append(tskey)
|
||||||
|
|
||||||
match_str: str = '\n'.join(sorted(set(possible_matches)))
|
match_str: str = '\n'.join(sorted(possible_matches))
|
||||||
raise TimeseriesNotFound(
|
raise TimeseriesNotFound(
|
||||||
f'No entry for `{fqme}`?\n'
|
f'No entry for `{fqme}`?\n'
|
||||||
f'Maybe you need a more specific fqme-key like:\n\n'
|
f'Maybe you need a more specific fqme-key like:\n\n'
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,8 @@ from ._dedupe_smart import (
|
||||||
dedupe_ohlcv_smart as dedupe_ohlcv_smart,
|
dedupe_ohlcv_smart as dedupe_ohlcv_smart,
|
||||||
)
|
)
|
||||||
from ._history import (
|
from ._history import (
|
||||||
iter_shm_buffer_refs as iter_shm_buffer_refs,
|
|
||||||
iter_dfs_from_shms as iter_dfs_from_shms,
|
iter_dfs_from_shms as iter_dfs_from_shms,
|
||||||
manage_history as manage_history,
|
manage_history as manage_history,
|
||||||
parse_shm_buffer_path as parse_shm_buffer_path,
|
|
||||||
resolve_shm_buffer_dtype as resolve_shm_buffer_dtype,
|
|
||||||
ShmBufferRef as ShmBufferRef,
|
|
||||||
)
|
)
|
||||||
from ._annotate import (
|
from ._annotate import (
|
||||||
markup_gaps as markup_gaps,
|
markup_gaps as markup_gaps,
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,15 @@ Historical TSP (time-series processing) lowlevel mgmt machinery and biz logic fo
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import platform
|
import platform
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
from string import hexdigits
|
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
Generator,
|
Generator,
|
||||||
Literal,
|
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,7 +56,6 @@ import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from piker.brokers import NoData
|
from piker.brokers import NoData
|
||||||
from piker.brokers import get_brokermod
|
|
||||||
from piker.accounting import (
|
from piker.accounting import (
|
||||||
MktPair,
|
MktPair,
|
||||||
)
|
)
|
||||||
|
|
@ -68,10 +64,7 @@ from piker.log import (
|
||||||
get_console_log,
|
get_console_log,
|
||||||
)
|
)
|
||||||
from tractor.ipc._shm import ShmArray
|
from tractor.ipc._shm import ShmArray
|
||||||
from ..data._sharedmem import (
|
from ..data._sharedmem import maybe_open_shm_array
|
||||||
attach_existing_shm_array,
|
|
||||||
maybe_open_shm_array,
|
|
||||||
)
|
|
||||||
from piker.data._source import (
|
from piker.data._source import (
|
||||||
def_iohlcv_fields,
|
def_iohlcv_fields,
|
||||||
)
|
)
|
||||||
|
|
@ -111,19 +104,6 @@ log = get_logger(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ShmBufferRef:
|
|
||||||
'''
|
|
||||||
Identify one Piker OHLCV shared-memory buffer.
|
|
||||||
|
|
||||||
'''
|
|
||||||
service: str
|
|
||||||
generation: str
|
|
||||||
fqme: str
|
|
||||||
kind: Literal['hist', 'rt']
|
|
||||||
path: Path
|
|
||||||
|
|
||||||
|
|
||||||
# `ShmArray` buffer sizing configuration:
|
# `ShmArray` buffer sizing configuration:
|
||||||
_mins_in_day = int(60 * 24)
|
_mins_in_day = int(60 * 24)
|
||||||
# how much is probably dependent on lifestyle
|
# how much is probably dependent on lifestyle
|
||||||
|
|
@ -260,7 +240,7 @@ async def shm_push_in_between(
|
||||||
prepend_index: int,
|
prepend_index: int,
|
||||||
backfill_until_dt: datetime,
|
backfill_until_dt: datetime,
|
||||||
|
|
||||||
update_start_on_prepend: bool = True,
|
update_start_on_prepend: bool = False,
|
||||||
|
|
||||||
) -> int:
|
) -> int:
|
||||||
|
|
||||||
|
|
@ -282,12 +262,16 @@ async def shm_push_in_between(
|
||||||
to_push,
|
to_push,
|
||||||
prepend=True,
|
prepend=True,
|
||||||
|
|
||||||
# Keep the readable `ShmArray.array` boundary aligned with
|
# XXX: only update the ._first index if no tsdb
|
||||||
# every completed provider prepend.
|
# segment was previously prepended by the
|
||||||
|
# parent task.
|
||||||
update_first=update_start_on_prepend,
|
update_first=update_start_on_prepend,
|
||||||
|
|
||||||
# An explicit index is only needed by callers retaining a
|
# XXX: only prepend from a manually calculated shm
|
||||||
# separately placed segment before the readable boundary.
|
# index if there was already a tsdb history
|
||||||
|
# segment prepended (since then the
|
||||||
|
# ._first.value is going to be wayyy in the
|
||||||
|
# past!)
|
||||||
start=(
|
start=(
|
||||||
prepend_index
|
prepend_index
|
||||||
if not update_start_on_prepend
|
if not update_start_on_prepend
|
||||||
|
|
@ -554,7 +538,7 @@ async def start_backfill(
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
# based on the sample step size, maybe load a certain amount history
|
# based on the sample step size, maybe load a certain amount history
|
||||||
update_start_on_prepend: bool = True
|
update_start_on_prepend: bool = False
|
||||||
if (
|
if (
|
||||||
_until_was_none := (backfill_until_dt is None)
|
_until_was_none := (backfill_until_dt is None)
|
||||||
):
|
):
|
||||||
|
|
@ -575,6 +559,8 @@ async def start_backfill(
|
||||||
60: {'years': 6},
|
60: {'years': 6},
|
||||||
}
|
}
|
||||||
period_duration: int = periods[timeframe]
|
period_duration: int = periods[timeframe]
|
||||||
|
update_start_on_prepend: bool = True
|
||||||
|
|
||||||
# NOTE: manually set the "latest" datetime which we intend to
|
# NOTE: manually set the "latest" datetime which we intend to
|
||||||
# backfill history "until" so as to adhere to the history
|
# backfill history "until" so as to adhere to the history
|
||||||
# settings above when the tsdb is detected as being empty.
|
# settings above when the tsdb is detected as being empty.
|
||||||
|
|
@ -761,7 +747,7 @@ async def start_backfill(
|
||||||
prepend_until_dt=backfill_until_dt,
|
prepend_until_dt=backfill_until_dt,
|
||||||
)
|
)
|
||||||
# The requested end bar is already the oldest published
|
# The requested end bar is already the oldest published
|
||||||
# sample. Keep it for storage merge, but do not
|
# sample. Keep it for idempotent storage merge, but do not
|
||||||
# consume another physical SHM row for the overlap.
|
# consume another physical SHM row for the overlap.
|
||||||
to_push: np.ndarray = to_store[
|
to_push: np.ndarray = to_store[
|
||||||
to_store['time'] < last_start_dt.timestamp()
|
to_store['time'] < last_start_dt.timestamp()
|
||||||
|
|
@ -1158,74 +1144,6 @@ async def load_tsdb_hist(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _prepend_tsdb_history(
|
|
||||||
shm: ShmArray,
|
|
||||||
tsdb_history: np.ndarray,
|
|
||||||
field_map: bidict|None,
|
|
||||||
|
|
||||||
) -> np.ndarray:
|
|
||||||
'''
|
|
||||||
Prepend stored rows beside the actual provider boundary.
|
|
||||||
|
|
||||||
'''
|
|
||||||
available: int = max(0, int(shm._first.value))
|
|
||||||
if (
|
|
||||||
not available
|
|
||||||
or
|
|
||||||
not len(tsdb_history)
|
|
||||||
):
|
|
||||||
return tsdb_history[:0]
|
|
||||||
|
|
||||||
frame: np.ndarray = shm.array
|
|
||||||
if not len(frame):
|
|
||||||
return tsdb_history[:0]
|
|
||||||
|
|
||||||
# XXX EDGE CASEs: the most recent provider frame can overlap
|
|
||||||
# prior TSDB history when its start is earlier than the latest
|
|
||||||
# stored sample.
|
|
||||||
#
|
|
||||||
# Alternatively, this can occur when the venue was closed (say
|
|
||||||
# over a weekend), causing a wall-clock time-series gap while the
|
|
||||||
# provider's query frame is larger than the current market's
|
|
||||||
# operating session. For example, IB's 1s default returns around
|
|
||||||
# 2k datums (~33.33m), which can include samples from before a
|
|
||||||
# closure. The wall-clock interval between frames then differs
|
|
||||||
# from the number of actual venue samples and may even look like
|
|
||||||
# a negative overlap when compared only by endpoints.
|
|
||||||
#
|
|
||||||
# The previous placement converted that elapsed interval into a
|
|
||||||
# physical SHM offset. Sparse venue series contain samples, not
|
|
||||||
# one row per wall-clock period, so closures accumulated as null
|
|
||||||
# reservations between otherwise adjacent data. Instead, wait
|
|
||||||
# until reverse provider retrieval establishes the actual first
|
|
||||||
# sample, retain its values for any overlap, and prepend only TSDB
|
|
||||||
# samples older than that boundary.
|
|
||||||
#
|
|
||||||
# TODO: assert overlapping TSDB and provider segments contain the
|
|
||||||
# same values before preferring the provider's copy.
|
|
||||||
time_key: str = (
|
|
||||||
field_map.inverse.get('time', 'time')
|
|
||||||
if field_map
|
|
||||||
else 'time'
|
|
||||||
)
|
|
||||||
earliest_t: float = frame['time'][0]
|
|
||||||
older: np.ndarray = tsdb_history[
|
|
||||||
tsdb_history[time_key] < earliest_t
|
|
||||||
]
|
|
||||||
|
|
||||||
# Stored history may extend further into the past than remaining
|
|
||||||
# SHM capacity. Keep only its most recent rows in that case.
|
|
||||||
to_push: np.ndarray = older[-available:]
|
|
||||||
if len(to_push):
|
|
||||||
shm.push(
|
|
||||||
to_push,
|
|
||||||
prepend=True,
|
|
||||||
field_map=field_map,
|
|
||||||
)
|
|
||||||
|
|
||||||
return to_push
|
|
||||||
|
|
||||||
|
|
||||||
async def tsdb_backfill(
|
async def tsdb_backfill(
|
||||||
mod: ModuleType,
|
mod: ModuleType,
|
||||||
storemod: ModuleType,
|
storemod: ModuleType,
|
||||||
|
|
@ -1311,7 +1229,7 @@ async def tsdb_backfill(
|
||||||
# NOTE: iabs to start backfilling from, reverse chronological,
|
# NOTE: iabs to start backfilling from, reverse chronological,
|
||||||
# ONLY AFTER the first history frame has been pushed to
|
# ONLY AFTER the first history frame has been pushed to
|
||||||
# mem!
|
# mem!
|
||||||
backfill_gap_from_shm_index: int = shm._first.value
|
backfill_gap_from_shm_index: int = shm._first.value + 1
|
||||||
|
|
||||||
# Prepend any tsdb history into the rt-shm-buffer which
|
# Prepend any tsdb history into the rt-shm-buffer which
|
||||||
# should NOW be getting filled with the most recent history
|
# should NOW be getting filled with the most recent history
|
||||||
|
|
@ -1400,29 +1318,76 @@ async def tsdb_backfill(
|
||||||
write_tsdb=True,
|
write_tsdb=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if last_tsdb_dt is not None:
|
||||||
|
|
||||||
|
# calc the index from which the tsdb data should be
|
||||||
|
# prepended, presuming there is a gap between the
|
||||||
|
# latest frame (loaded/read above) and the latest
|
||||||
|
# sample loaded from the tsdb.
|
||||||
|
backfill_diff: Duration = mr_start_dt - last_tsdb_dt
|
||||||
|
offset_s: float = backfill_diff.in_seconds()
|
||||||
|
|
||||||
|
# XXX EDGE CASEs: the most recent frame overlaps with
|
||||||
|
# prior tsdb history!!
|
||||||
|
# - so the latest frame's start time is earlier then
|
||||||
|
# the tsdb's latest sample.
|
||||||
|
# - alternatively this may also more generally occur
|
||||||
|
# when the venue was closed (say over the weeknd)
|
||||||
|
# causing a timeseries gap, AND the query frames size
|
||||||
|
# (eg. for ib's 1s we rx 2k datums ~= 33.33m) IS
|
||||||
|
# GREATER THAN the current venue-market's operating
|
||||||
|
# session (time) we will receive datums from BEFORE THE
|
||||||
|
# CLOSURE GAP and thus the `offset_s` value will be
|
||||||
|
# NEGATIVE! In this case we need to ensure we don't try
|
||||||
|
# to push datums that have already been recorded in the
|
||||||
|
# tsdb. In this case we instead only retreive and push
|
||||||
|
# the series portion missing from the db's data set.
|
||||||
|
# if offset_s < 0:
|
||||||
|
# non_overlap_diff: Duration = mr_end_dt - last_tsdb_dt
|
||||||
|
# non_overlap_offset_s: float = backfill_diff.in_seconds()
|
||||||
|
|
||||||
|
offset_samples: int = round(offset_s / timeframe)
|
||||||
|
|
||||||
|
# TODO: see if there's faster multi-field reads:
|
||||||
|
# https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields
|
||||||
|
# re-index with a `time` and index field
|
||||||
|
if offset_s > 0:
|
||||||
|
# NOTE XXX: ONLY when there is an actual gap
|
||||||
|
# between the earliest sample in the latest history
|
||||||
|
# frame do we want to NOT stick the latest tsdb
|
||||||
|
# history adjacent to that latest frame!
|
||||||
|
prepend_start = shm._first.value - offset_samples + 1
|
||||||
|
to_push = tsdb_history[-prepend_start:]
|
||||||
|
else:
|
||||||
|
# when there is overlap we want to remove the
|
||||||
|
# overlapping samples from the tsdb portion (taking
|
||||||
|
# instead the latest frame's values since THEY
|
||||||
|
# SHOULD BE THE SAME) and prepend DIRECTLY adjacent
|
||||||
|
# to the latest frame!
|
||||||
|
# TODO: assert the overlap segment array contains
|
||||||
|
# the same values!?!
|
||||||
|
prepend_start = shm._first.value
|
||||||
|
to_push = tsdb_history[-(shm._first.value):offset_samples - 1]
|
||||||
|
|
||||||
|
# tsdb history is so far in the past we can't fit it in
|
||||||
|
# shm buffer space so simply don't load it!
|
||||||
|
if prepend_start > 0:
|
||||||
|
shm.push(
|
||||||
|
to_push,
|
||||||
|
|
||||||
|
# insert the history pre a "days worth" of samples
|
||||||
|
# to leave some real-time buffer space at the end.
|
||||||
|
prepend=True,
|
||||||
|
# update_first=False,
|
||||||
|
start=prepend_start,
|
||||||
|
field_map=storemod.ohlc_key_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(f'Loaded {to_push.shape} datums from storage')
|
||||||
|
|
||||||
# 2nd nursery END
|
# 2nd nursery END
|
||||||
|
|
||||||
if last_tsdb_dt is not None:
|
if last_tsdb_dt is not None:
|
||||||
# Provider frames contain venue samples, not one row
|
|
||||||
# per wall-clock period. Wait until reverse retrieval
|
|
||||||
# establishes the actual earliest sample, then prepend
|
|
||||||
# storage directly beside it without reserving closure
|
|
||||||
# rows.
|
|
||||||
to_push: np.ndarray = _prepend_tsdb_history(
|
|
||||||
shm,
|
|
||||||
tsdb_history,
|
|
||||||
field_map=storemod.ohlc_key_map,
|
|
||||||
)
|
|
||||||
log.info(
|
|
||||||
f'Loaded {to_push.shape} datums from storage'
|
|
||||||
)
|
|
||||||
if len(to_push):
|
|
||||||
await notify_backfill(
|
|
||||||
sampler_stream,
|
|
||||||
mkt,
|
|
||||||
timeframe,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Repair only after reverse backfill releases providers
|
# Repair only after reverse backfill releases providers
|
||||||
# such as IB which permit one history request at a time.
|
# such as IB which permit one history request at a time.
|
||||||
# TODO: ideally these null segments can never exist!
|
# TODO: ideally these null segments can never exist!
|
||||||
|
|
@ -1716,182 +1681,8 @@ async def manage_history(
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
||||||
def parse_shm_buffer_path(
|
|
||||||
path: Path,
|
|
||||||
|
|
||||||
) -> ShmBufferRef|None:
|
|
||||||
'''
|
|
||||||
Parse one Piker OHLCV shared-memory filename.
|
|
||||||
|
|
||||||
'''
|
|
||||||
kind: Literal['hist', 'rt']
|
|
||||||
if path.name.endswith('.hist'):
|
|
||||||
kind = 'hist'
|
|
||||||
elif path.name.endswith('.rt'):
|
|
||||||
kind = 'rt'
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
stem: str = path.name.removesuffix(f'.{kind}')
|
|
||||||
service_prefix, open_sep, generation_tail = stem.partition('[')
|
|
||||||
generation, close_sep, fqme = generation_tail.partition('].')
|
|
||||||
if (
|
|
||||||
open_sep != '['
|
|
||||||
or
|
|
||||||
close_sep != '].'
|
|
||||||
or
|
|
||||||
not service_prefix.startswith('piker.')
|
|
||||||
or
|
|
||||||
not generation
|
|
||||||
or
|
|
||||||
not fqme
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
service: str = service_prefix.removeprefix('piker.')
|
|
||||||
generation_valid: bool = (
|
|
||||||
len(generation) == 16
|
|
||||||
and
|
|
||||||
generation[8] == '-'
|
|
||||||
and
|
|
||||||
generation[13] == '-'
|
|
||||||
and
|
|
||||||
all(
|
|
||||||
char in hexdigits
|
|
||||||
for index, char in enumerate(generation)
|
|
||||||
if index not in {8, 13}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
service_valid: bool = (
|
|
||||||
bool(service)
|
|
||||||
and
|
|
||||||
service[0].isalnum()
|
|
||||||
and
|
|
||||||
service[-1].isalnum()
|
|
||||||
and
|
|
||||||
all(
|
|
||||||
char.isalnum()
|
|
||||||
or
|
|
||||||
char in {'-', '_', '.'}
|
|
||||||
for char in service
|
|
||||||
)
|
|
||||||
)
|
|
||||||
fqme_valid: bool = (
|
|
||||||
fqme not in {'.', '..'}
|
|
||||||
and
|
|
||||||
Path(fqme).name == fqme
|
|
||||||
and
|
|
||||||
'/' not in fqme
|
|
||||||
and
|
|
||||||
'\\' not in fqme
|
|
||||||
and
|
|
||||||
'[' not in fqme
|
|
||||||
and
|
|
||||||
']' not in fqme
|
|
||||||
)
|
|
||||||
if not (
|
|
||||||
generation_valid
|
|
||||||
and
|
|
||||||
service_valid
|
|
||||||
and
|
|
||||||
fqme_valid
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return ShmBufferRef(
|
|
||||||
service=service,
|
|
||||||
generation=generation,
|
|
||||||
fqme=fqme,
|
|
||||||
kind=kind,
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_shm_buffer_dtype(
|
|
||||||
ref: ShmBufferRef,
|
|
||||||
size: int,
|
|
||||||
) -> np.dtype:
|
|
||||||
'''
|
|
||||||
Resolve a buffer dtype from its size and backend declarations.
|
|
||||||
|
|
||||||
'''
|
|
||||||
canonical: np.dtype = np.dtype(def_iohlcv_fields)
|
|
||||||
provider_candidates: list[np.dtype] = []
|
|
||||||
brokername: str = ref.fqme.rpartition('.')[2]
|
|
||||||
try:
|
|
||||||
brokermod: ModuleType|None = get_brokermod(brokername)
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
brokermod = None
|
|
||||||
|
|
||||||
if brokermod is not None:
|
|
||||||
provider_dtype = getattr(brokermod, '_ohlc_dtype', None)
|
|
||||||
if provider_dtype is not None:
|
|
||||||
provider_candidates.append(np.dtype(provider_dtype))
|
|
||||||
|
|
||||||
candidates: list[np.dtype] = (
|
|
||||||
provider_candidates
|
|
||||||
if provider_candidates
|
|
||||||
else [canonical]
|
|
||||||
)
|
|
||||||
|
|
||||||
actual_bytes: int = ref.path.stat().st_size
|
|
||||||
matches: dict[tuple, np.dtype] = {
|
|
||||||
tuple(candidate.descr): candidate
|
|
||||||
for candidate in candidates
|
|
||||||
if candidate.itemsize * size == actual_bytes
|
|
||||||
}
|
|
||||||
if len(matches) != 1:
|
|
||||||
descriptions: list[str] = [
|
|
||||||
f'{candidate.itemsize}B:{candidate.names}'
|
|
||||||
for candidate in candidates
|
|
||||||
]
|
|
||||||
raise ValueError(
|
|
||||||
f'Can not resolve dtype for {ref.path.name}: '
|
|
||||||
f'{actual_bytes} bytes over {size} rows; '
|
|
||||||
f'candidates={descriptions}'
|
|
||||||
)
|
|
||||||
return next(iter(matches.values()))
|
|
||||||
|
|
||||||
|
|
||||||
def iter_shm_buffer_refs(
|
|
||||||
fqme: str|None = None,
|
|
||||||
shmdir: Path = Path('/dev/shm/'),
|
|
||||||
) -> Generator[ShmBufferRef, None, None]:
|
|
||||||
'''
|
|
||||||
Yield exact Piker OHLCV SHM identities in stable order.
|
|
||||||
|
|
||||||
'''
|
|
||||||
if not shmdir.is_dir():
|
|
||||||
return
|
|
||||||
|
|
||||||
for path in sorted(
|
|
||||||
shmdir.iterdir(),
|
|
||||||
key=lambda candidate: candidate.name,
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
path.is_symlink()
|
|
||||||
or
|
|
||||||
not path.is_file()
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
ref: ShmBufferRef|None = parse_shm_buffer_path(path)
|
|
||||||
if (
|
|
||||||
ref is not None
|
|
||||||
and
|
|
||||||
(
|
|
||||||
fqme is None
|
|
||||||
or
|
|
||||||
ref.fqme == fqme
|
|
||||||
)
|
|
||||||
):
|
|
||||||
yield ref
|
|
||||||
|
|
||||||
|
|
||||||
def iter_dfs_from_shms(
|
def iter_dfs_from_shms(
|
||||||
fqme: str,
|
fqme: str
|
||||||
shm_name: str|None = None,
|
|
||||||
shmdir: Path = Path('/dev/shm/'),
|
|
||||||
refs: list[ShmBufferRef]|None = None,
|
|
||||||
) -> Generator[
|
) -> Generator[
|
||||||
tuple[Path, ShmArray, pl.DataFrame],
|
tuple[Path, ShmArray, pl.DataFrame],
|
||||||
None,
|
None,
|
||||||
|
|
@ -1903,48 +1694,46 @@ def iter_dfs_from_shms(
|
||||||
'rt': _default_rt_size,
|
'rt': _default_rt_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
selected_refs: list[ShmBufferRef] = (
|
# load all detected shm buffer files which have the
|
||||||
refs
|
# passed FQME pattern in the file name.
|
||||||
if refs is not None
|
shmfiles: list[Path] = []
|
||||||
else [
|
shmdir = Path('/dev/shm/')
|
||||||
candidate
|
|
||||||
for candidate in iter_shm_buffer_refs(
|
for shmfile in shmdir.glob(f'*{fqme}*'):
|
||||||
fqme=fqme,
|
filename: str = shmfile.name
|
||||||
shmdir=shmdir,
|
|
||||||
)
|
# skip index files
|
||||||
if (
|
if (
|
||||||
shm_name is None
|
'_first' in filename
|
||||||
or
|
or '_last' in filename
|
||||||
candidate.path.name == shm_name
|
):
|
||||||
)
|
continue
|
||||||
]
|
|
||||||
)
|
assert shmfile.is_file()
|
||||||
for ref in selected_refs:
|
log.debug(f'Found matching shm buffer file: {filename}')
|
||||||
shmfile: Path = ref.path
|
shmfiles.append(shmfile)
|
||||||
log.debug(f'Found matching shm buffer file: {shmfile.name}')
|
|
||||||
|
for shmfile in shmfiles:
|
||||||
|
|
||||||
# lookup array buffer size based on file suffix
|
# lookup array buffer size based on file suffix
|
||||||
# being either .rt or .hist
|
# being either .rt or .hist
|
||||||
size: int = sizes[ref.kind]
|
key: str = shmfile.name.rsplit('.')[-1]
|
||||||
|
|
||||||
|
# skip FSP buffers for now..
|
||||||
|
if key not in sizes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
size: int = sizes[key]
|
||||||
|
|
||||||
# attach to any shm buffer, load array into polars df,
|
# attach to any shm buffer, load array into polars df,
|
||||||
# write to local parquet file.
|
# write to local parquet file.
|
||||||
try:
|
shm, opened = maybe_open_shm_array(
|
||||||
dtype: np.dtype = resolve_shm_buffer_dtype(ref, size)
|
|
||||||
shm: ShmArray = attach_existing_shm_array(
|
|
||||||
key=shmfile.name,
|
key=shmfile.name,
|
||||||
size=size,
|
size=size,
|
||||||
dtype=dtype,
|
dtype=def_iohlcv_fields,
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
except (
|
assert not opened
|
||||||
FileNotFoundError,
|
|
||||||
ValueError,
|
|
||||||
) as err:
|
|
||||||
log.warning(
|
|
||||||
f'Could not attach SHM {shmfile.name}: {err}'
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
ohlcv: np.ndarray = shm.array
|
ohlcv: np.ndarray = shm.array
|
||||||
df: pl.DataFrame = np2pl(ohlcv)
|
df: pl.DataFrame = np2pl(ohlcv)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,7 @@ def expect(
|
||||||
|
|
||||||
def run_pdb_commands(
|
def run_pdb_commands(
|
||||||
commands: list[str],
|
commands: list[str],
|
||||||
initial_cmd: str = (
|
initial_cmd: str = 'piker store ldshm xmrusdt.usdtm.perp.binance',
|
||||||
'piker store shm xmrusdt.usdtm.perp.binance '
|
|
||||||
'--write-parquet'
|
|
||||||
),
|
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
print_output: bool = True,
|
print_output: bool = True,
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
|
|
@ -148,10 +145,7 @@ class InteractivePdbSession:
|
||||||
'''
|
'''
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
cmd: str = (
|
cmd: str = 'piker store ldshm xmrusdt.usdtm.perp.binance',
|
||||||
'piker store shm xmrusdt.usdtm.perp.binance '
|
|
||||||
'--write-parquet'
|
|
||||||
),
|
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
):
|
):
|
||||||
self.cmd: str = cmd
|
self.cmd: str = cmd
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
Deterministic history-backfill regressions.
|
Deterministic history-backfill regressions.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
@ -35,7 +34,6 @@ from piker.tsp._history import (
|
||||||
notify_backfill,
|
notify_backfill,
|
||||||
publish_latest_frame,
|
publish_latest_frame,
|
||||||
start_backfill,
|
start_backfill,
|
||||||
tsdb_backfill,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,197 +216,6 @@ def test_latest_frame_is_persisted_before_shm() -> None:
|
||||||
assert events == ['storage', 'shm']
|
assert events == ['storage', 'shm']
|
||||||
|
|
||||||
|
|
||||||
def test_tsdb_prepend_uses_actual_provider_boundary(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Sparse venue history must not reserve wall-clock rows in SHM.
|
|
||||||
|
|
||||||
NVDA's 60s startup converted elapsed calendar minutes between
|
|
||||||
NativeDB and the latest IB frame into physical SHM offsets. IB
|
|
||||||
returned only trading bars, leaving 42,720 zero rows even though
|
|
||||||
both valid margins ended at the same timestamp. Model completed
|
|
||||||
reverse retrieval with a provider boundary overlapping NativeDB.
|
|
||||||
Exercise real ``ShmArray.push()`` indexing and prove the stored,
|
|
||||||
reverse-provider, and latest frames form one contiguous lossless
|
|
||||||
sequence without an explicit wall-clock offset.
|
|
||||||
|
|
||||||
'''
|
|
||||||
stored = np.zeros(
|
|
||||||
3,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
stored['time'] = [60, 120, 180]
|
|
||||||
older_reverse = np.zeros(
|
|
||||||
3,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
older_reverse['time'] = [180, 240, 300]
|
|
||||||
newer_reverse = np.zeros(
|
|
||||||
3,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
newer_reverse['time'] = [300, 360, 420]
|
|
||||||
latest = np.zeros(
|
|
||||||
2,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
latest['time'] = [420, 480]
|
|
||||||
storage_frames: list[np.ndarray] = []
|
|
||||||
notifications: list[dict] = []
|
|
||||||
startup_done = trio.Event()
|
|
||||||
|
|
||||||
class Storage:
|
|
||||||
'''
|
|
||||||
Capture the complete provider delta written to NativeDB.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def update_ohlcv(
|
|
||||||
self,
|
|
||||||
fqme: str,
|
|
||||||
ohlcv: np.ndarray,
|
|
||||||
timeframe: int,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Record one reverse provider response.
|
|
||||||
|
|
||||||
'''
|
|
||||||
storage_frames.append(ohlcv.copy())
|
|
||||||
|
|
||||||
async def load(
|
|
||||||
self,
|
|
||||||
fqme: str,
|
|
||||||
timeframe: int,
|
|
||||||
) -> tuple:
|
|
||||||
'''
|
|
||||||
Return overlapping NativeDB history and its boundaries.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return (
|
|
||||||
stored,
|
|
||||||
from_timestamp(60),
|
|
||||||
from_timestamp(180),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Sampler:
|
|
||||||
'''
|
|
||||||
Accept deterministic backfill notifications.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def send(self, msg) -> None:
|
|
||||||
'''
|
|
||||||
Capture notification without actor IPC.
|
|
||||||
|
|
||||||
'''
|
|
||||||
notifications.append(msg)
|
|
||||||
|
|
||||||
mkt = SimpleNamespace(
|
|
||||||
fqme='nvda.nasdaq.ib',
|
|
||||||
dst=SimpleNamespace(atype='stock'),
|
|
||||||
src=SimpleNamespace(atype='fiat'),
|
|
||||||
get_fqme=lambda **kwargs: 'nvda.nasdaq.ib',
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_hist(*args, **kwargs):
|
|
||||||
'''
|
|
||||||
Return inclusive reverse frames overlapping each SHM
|
|
||||||
boundary.
|
|
||||||
|
|
||||||
'''
|
|
||||||
end_dt = kwargs['end_dt']
|
|
||||||
if end_dt is None:
|
|
||||||
return (
|
|
||||||
latest,
|
|
||||||
from_timestamp(420),
|
|
||||||
from_timestamp(480),
|
|
||||||
)
|
|
||||||
|
|
||||||
end_t: float = end_dt.timestamp()
|
|
||||||
reverse: np.ndarray = (
|
|
||||||
newer_reverse
|
|
||||||
if end_t == 420
|
|
||||||
else older_reverse
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
reverse,
|
|
||||||
from_timestamp(reverse['time'][0]),
|
|
||||||
from_timestamp(reverse['time'][-1]),
|
|
||||||
)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_history_client(mkt):
|
|
||||||
'''
|
|
||||||
Yield the deterministic provider and default frame config.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield get_hist, {}
|
|
||||||
|
|
||||||
async def finish_null_repair(**kwargs) -> None:
|
|
||||||
'''
|
|
||||||
Signal that provider and NativeDB prepends both completed.
|
|
||||||
|
|
||||||
'''
|
|
||||||
startup_done.set()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
'piker.tsp._history.maybe_fill_null_segments',
|
|
||||||
finish_null_repair,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def main() -> None:
|
|
||||||
shm_key = f'test_sparse_prepend_{uuid4().hex}'
|
|
||||||
async with tractor.open_root_actor(
|
|
||||||
name=shm_key,
|
|
||||||
tpt_bind_addrs=[('127.0.0.1', 0)],
|
|
||||||
):
|
|
||||||
shm, opened = maybe_open_shm_array(
|
|
||||||
key=shm_key,
|
|
||||||
size=16,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
append_start_index=12,
|
|
||||||
)
|
|
||||||
assert opened
|
|
||||||
storage = Storage()
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
nursery.start_soon(partial(
|
|
||||||
tsdb_backfill,
|
|
||||||
mod=SimpleNamespace(
|
|
||||||
name='fake',
|
|
||||||
open_history_client=open_history_client,
|
|
||||||
),
|
|
||||||
storemod=SimpleNamespace(
|
|
||||||
ohlc_key_map=ohlc_key_map,
|
|
||||||
),
|
|
||||||
storage=storage,
|
|
||||||
mkt=mkt,
|
|
||||||
shm=shm,
|
|
||||||
timeframe=60,
|
|
||||||
sampler_stream=Sampler(),
|
|
||||||
))
|
|
||||||
with trio.fail_after(0.5):
|
|
||||||
await startup_done.wait()
|
|
||||||
nursery.cancel_scope.cancel()
|
|
||||||
|
|
||||||
assert shm.array['time'].tolist() == [
|
|
||||||
60,
|
|
||||||
120,
|
|
||||||
180,
|
|
||||||
240,
|
|
||||||
300,
|
|
||||||
360,
|
|
||||||
420,
|
|
||||||
480,
|
|
||||||
]
|
|
||||||
assert not np.any(shm.array['time'] <= 0)
|
|
||||||
|
|
||||||
trio.run(main)
|
|
||||||
assert len(storage_frames) == 3
|
|
||||||
assert storage_frames[0]['time'].tolist() == [420, 480]
|
|
||||||
assert storage_frames[1]['time'].tolist() == [300, 360, 420]
|
|
||||||
assert storage_frames[2]['time'].tolist() == [180, 240, 300]
|
|
||||||
assert len(notifications) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_ib_latest_frame_round_trips_through_nativedb(
|
def test_ib_latest_frame_round_trips_through_nativedb(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
'''
|
|
||||||
IB asyncio method-proxy regressions.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import trio
|
|
||||||
|
|
||||||
from piker.brokers.ib import api as ib_api
|
|
||||||
from piker.brokers.ib.api import (
|
|
||||||
MethodProxy,
|
|
||||||
relay_client_proxy_messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeChannel:
|
|
||||||
'''
|
|
||||||
Model the Trio side of an asyncio linked-task channel.
|
|
||||||
|
|
||||||
'''
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.requests: list[tuple] = []
|
|
||||||
(
|
|
||||||
self._tx,
|
|
||||||
self._rx,
|
|
||||||
) = trio.open_memory_channel[Any](10)
|
|
||||||
|
|
||||||
async def send(self, msg: tuple) -> None:
|
|
||||||
'''
|
|
||||||
Capture one Trio-to-asyncio method request.
|
|
||||||
|
|
||||||
'''
|
|
||||||
self.requests.append(msg)
|
|
||||||
|
|
||||||
async def receive(self) -> Any:
|
|
||||||
'''
|
|
||||||
Receive one simulated asyncio-to-Trio response.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return await self._rx.receive()
|
|
||||||
|
|
||||||
def closed(self) -> bool:
|
|
||||||
'''
|
|
||||||
Report an open channel for the lifetime of each test.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def respond(self, msg: Any) -> None:
|
|
||||||
'''
|
|
||||||
Send one response or status event to the proxy relay.
|
|
||||||
|
|
||||||
'''
|
|
||||||
await self._tx.send(msg)
|
|
||||||
|
|
||||||
async def close_responses(self) -> None:
|
|
||||||
'''
|
|
||||||
Close the simulated asyncio-to-Trio response channel.
|
|
||||||
|
|
||||||
'''
|
|
||||||
await self._tx.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
def test_method_proxy_routes_after_idle_status_events() -> None:
|
|
||||||
'''
|
|
||||||
Idle IB status traffic must not break first-symbol qualification.
|
|
||||||
|
|
||||||
``open_client_proxy()`` previously broadcast a one-slot channel
|
|
||||||
to an always-running status relay and an idle method receiver.
|
|
||||||
Status events advanced only the relay, so a first order for an
|
|
||||||
uncached symbol raised ``Lagged`` before receiving
|
|
||||||
``get_sym_details()``. Feed several idle events through the
|
|
||||||
sole-reader relay, then start two method calls and return their
|
|
||||||
responses in reverse order. Both exact results prove status
|
|
||||||
traffic cannot overrun a dormant caller and concurrent responses
|
|
||||||
remain correlated by request ID.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def main() -> None:
|
|
||||||
chan = FakeChannel()
|
|
||||||
proxy = MethodProxy(
|
|
||||||
chan,
|
|
||||||
event_table={},
|
|
||||||
asyncio_ns=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
results: dict[str, str] = {}
|
|
||||||
|
|
||||||
async def call(name: str) -> None:
|
|
||||||
results[name] = await proxy._run_method(meth=name)
|
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
nursery.start_soon(
|
|
||||||
relay_client_proxy_messages,
|
|
||||||
chan,
|
|
||||||
proxy,
|
|
||||||
)
|
|
||||||
for i in range(3):
|
|
||||||
await chan.respond((
|
|
||||||
'event',
|
|
||||||
{'reason': f'farm-status-{i}'},
|
|
||||||
))
|
|
||||||
|
|
||||||
nursery.start_soon(call, 'first')
|
|
||||||
nursery.start_soon(call, 'second')
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while len(chan.requests) < 2:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
|
|
||||||
mids: dict[str, int] = {
|
|
||||||
meth: mid
|
|
||||||
for meth, _kwargs, mid in chan.requests
|
|
||||||
}
|
|
||||||
await chan.respond({
|
|
||||||
'mid': mids['second'],
|
|
||||||
'result': 'second-result',
|
|
||||||
})
|
|
||||||
await chan.respond({
|
|
||||||
'mid': mids['first'],
|
|
||||||
'result': 'first-result',
|
|
||||||
})
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while len(results) < 2:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
nursery.cancel_scope.cancel()
|
|
||||||
|
|
||||||
assert results == {
|
|
||||||
'first': 'first-result',
|
|
||||||
'second': 'second-result',
|
|
||||||
}
|
|
||||||
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
|
|
||||||
def test_method_proxy_relay_accepts_clean_eof() -> None:
|
|
||||||
'''
|
|
||||||
Normal asyncio relay completion must not fail proxy teardown.
|
|
||||||
|
|
||||||
The sole-reader relay blocks in ``LinkedTaskChannel.receive()``
|
|
||||||
until the asyncio task exits and closes its Trio send channel.
|
|
||||||
Close the fake response sender before entering the relay and
|
|
||||||
prove the resulting ``EndOfChannel`` is graceful completion.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def main() -> None:
|
|
||||||
chan = FakeChannel()
|
|
||||||
proxy = MethodProxy(
|
|
||||||
chan,
|
|
||||||
event_table={},
|
|
||||||
asyncio_ns=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
await chan.close_responses()
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
await relay_client_proxy_messages(chan, proxy)
|
|
||||||
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
|
|
||||||
def test_method_proxy_drops_cancelled_call_response() -> None:
|
|
||||||
'''
|
|
||||||
A cancelled symbol search must not skew the next method response.
|
|
||||||
|
|
||||||
Search timeouts can cancel a Trio caller after its request
|
|
||||||
reaches the asyncio client. Wait until one request is sent,
|
|
||||||
cancel its caller, and then deliver the orphaned response. The
|
|
||||||
sole-reader relay must drop that stale ``mid`` without failing; a
|
|
||||||
subsequent call must still receive its own exact response.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def main() -> None:
|
|
||||||
chan = FakeChannel()
|
|
||||||
proxy = MethodProxy(
|
|
||||||
chan,
|
|
||||||
event_table={},
|
|
||||||
asyncio_ns=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
call_scope = trio.CancelScope()
|
|
||||||
cancelled = trio.Event()
|
|
||||||
result: list[str] = []
|
|
||||||
|
|
||||||
async def cancelled_call() -> None:
|
|
||||||
with call_scope:
|
|
||||||
try:
|
|
||||||
await proxy._run_method(meth='cancelled')
|
|
||||||
finally:
|
|
||||||
cancelled.set()
|
|
||||||
|
|
||||||
async def next_call() -> None:
|
|
||||||
value = await proxy._run_method(meth='next')
|
|
||||||
result.append(value)
|
|
||||||
|
|
||||||
async with trio.open_nursery() as nursery:
|
|
||||||
nursery.start_soon(
|
|
||||||
relay_client_proxy_messages,
|
|
||||||
chan,
|
|
||||||
proxy,
|
|
||||||
)
|
|
||||||
nursery.start_soon(cancelled_call)
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while len(chan.requests) < 1:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
cancelled_mid: int = chan.requests[0][2]
|
|
||||||
call_scope.cancel()
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
await cancelled.wait()
|
|
||||||
assert proxy._pending == {}
|
|
||||||
assert (
|
|
||||||
proxy._request_methods[cancelled_mid]
|
|
||||||
==
|
|
||||||
'cancelled'
|
|
||||||
)
|
|
||||||
|
|
||||||
await chan.respond({
|
|
||||||
'mid': cancelled_mid,
|
|
||||||
'result': 'stale',
|
|
||||||
})
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while cancelled_mid in proxy._request_methods:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
nursery.start_soon(next_call)
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while len(chan.requests) < 2:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
next_mid: int = chan.requests[1][2]
|
|
||||||
await chan.respond({
|
|
||||||
'mid': next_mid,
|
|
||||||
'result': 'fresh',
|
|
||||||
})
|
|
||||||
with trio.fail_after(0.1):
|
|
||||||
while not result:
|
|
||||||
await trio.lowlevel.checkpoint()
|
|
||||||
nursery.cancel_scope.cancel()
|
|
||||||
|
|
||||||
assert result == ['fresh']
|
|
||||||
|
|
||||||
trio.run(main)
|
|
||||||
|
|
||||||
|
|
||||||
def test_method_proxy_warns_when_metadata_is_evicted(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Unresolved metadata eviction must remain operator-visible.
|
|
||||||
|
|
||||||
Cancelled calls whose asyncio responses never arrive retain a
|
|
||||||
bounded method name solely for later stale-response diagnostics.
|
|
||||||
Fill that table beyond its limit and prove the oldest entry is
|
|
||||||
evicted with its request ID, method name, and configured bound in
|
|
||||||
the warning.
|
|
||||||
|
|
||||||
'''
|
|
||||||
proxy = MethodProxy(
|
|
||||||
FakeChannel(),
|
|
||||||
event_table={},
|
|
||||||
asyncio_ns=SimpleNamespace(),
|
|
||||||
)
|
|
||||||
warnings: list[str] = []
|
|
||||||
monkeypatch.setattr(ib_api.log, 'warning', warnings.append)
|
|
||||||
|
|
||||||
for mid in range(257):
|
|
||||||
proxy._track_request_method(mid, f'method-{mid}')
|
|
||||||
|
|
||||||
assert len(proxy._request_methods) == 256
|
|
||||||
assert 0 not in proxy._request_methods
|
|
||||||
assert warnings == [
|
|
||||||
'Evicting unresolved method-request metadata,\n'
|
|
||||||
'mid: 0\n'
|
|
||||||
"meth: 'method-0'\n"
|
|
||||||
'pending metadata limit: 256\n'
|
|
||||||
]
|
|
||||||
|
|
@ -16,21 +16,19 @@ import polars as pl
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from piker import tsp
|
from piker import tsp
|
||||||
from piker.data import def_iohlcv_fields
|
|
||||||
from piker.storage import cli as storage_cli
|
from piker.storage import cli as storage_cli
|
||||||
from piker.storage.cli import (
|
from piker.storage.cli import (
|
||||||
_shm_period_and_invalid_count,
|
_shm_period_and_invalid_count,
|
||||||
_summarize_shm_frame,
|
ldshm,
|
||||||
shm as shm_cmd,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_shm_detects_nulls_without_skewing_period() -> None:
|
def test_ldshm_detects_nulls_without_skewing_period() -> None:
|
||||||
'''
|
'''
|
||||||
Null SHM slots must stop persistence without skewing cadence.
|
Null SHM slots must stop persistence without skewing cadence.
|
||||||
|
|
||||||
A live QQQ buffer contained an interior run of zero-time rows.
|
A live QQQ buffer contained an interior run of zero-time rows.
|
||||||
``shm`` included them in period inference and then sent them to
|
``ldshm`` included them in period inference and then sent them to
|
||||||
NativeDB, which correctly rejected the replacement. Build a
|
NativeDB, which correctly rejected the replacement. Build a
|
||||||
60-second series with the same interior hole and enough
|
60-second series with the same interior hole and enough
|
||||||
bars around the gap. Prove inspection counts every null while
|
bars around the gap. Prove inspection counts every null while
|
||||||
|
|
@ -54,7 +52,7 @@ def test_shm_detects_nulls_without_skewing_period() -> None:
|
||||||
assert invalid_count == 2
|
assert invalid_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_shm_period_is_an_observed_step() -> None:
|
def test_ldshm_period_is_an_observed_step() -> None:
|
||||||
'''
|
'''
|
||||||
Sparse timestamps must not invent a storage timeframe.
|
Sparse timestamps must not invent a storage timeframe.
|
||||||
|
|
||||||
|
|
@ -73,13 +71,13 @@ def test_shm_period_is_an_observed_step() -> None:
|
||||||
assert invalid_count == 0
|
assert invalid_count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_shm_counts_every_invalid_timestamp() -> None:
|
def test_ldshm_counts_every_invalid_timestamp() -> None:
|
||||||
'''
|
'''
|
||||||
Corrupt timestamps stop persistence just like null SHM slots.
|
Corrupt timestamps stop persistence just like null SHM slots.
|
||||||
|
|
||||||
Exact zero denotes an unpublished slot, while negative, NaN, and
|
Exact zero denotes an unpublished slot, while negative, NaN, and
|
||||||
infinite values indicate corruption. NativeDB rejects them all,
|
infinite values indicate corruption. NativeDB rejects them all,
|
||||||
but allowing a replacement attempt would still abort ``shm``.
|
but allowing a replacement attempt would still abort ``ldshm``.
|
||||||
Mix each invalid class into a regular series and prove the same
|
Mix each invalid class into a regular series and prove the same
|
||||||
snapshot inspection counts all four while preserving cadence.
|
snapshot inspection counts all four while preserving cadence.
|
||||||
|
|
||||||
|
|
@ -100,7 +98,7 @@ def test_shm_counts_every_invalid_timestamp() -> None:
|
||||||
assert invalid_count == 4
|
assert invalid_count == 4
|
||||||
|
|
||||||
|
|
||||||
def test_shm_invalid_snapshot_never_reaches_storage(
|
def test_ldshm_invalid_snapshot_never_reaches_storage(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -208,156 +206,7 @@ def test_shm_invalid_snapshot_never_reaches_storage(
|
||||||
open_annotations,
|
open_annotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
shm_cmd(
|
ldshm('qqq.nasdaq.ib')
|
||||||
'qqq.nasdaq.ib',
|
|
||||||
write_parquet=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_write_without_reload_uses_deduped_markup_frame(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
A Parquet write without SHM reload must not need a reload
|
|
||||||
payload.
|
|
||||||
|
|
||||||
``ldshm`` created ``new`` only in reload but used it for markup.
|
|
||||||
Its no-reload mode crashed after a durable write. Arrange one
|
|
||||||
gap, explicitly enable persistence with reload disabled, and
|
|
||||||
prove markup receives the deduplicated frame without any SHM
|
|
||||||
mutation.
|
|
||||||
|
|
||||||
'''
|
|
||||||
array = np.zeros(
|
|
||||||
3,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
array['index'] = np.arange(3)
|
|
||||||
array['time'] = [60, 120, 240]
|
|
||||||
array['open'] = [10, 11, 12]
|
|
||||||
array['high'] = [12, 13, 14]
|
|
||||||
array['low'] = [8, 9, 10]
|
|
||||||
array['close'] = [11, 12, 13]
|
|
||||||
array['volume'] = [100, 101, 102]
|
|
||||||
frame = tsp.np2pl(array)
|
|
||||||
shm = SimpleNamespace(array=array)
|
|
||||||
shmfile = Path('piker.datad[aaaaaaaa-aaaa-aa].x.test.hist')
|
|
||||||
writes: list[pl.DataFrame] = []
|
|
||||||
markups: list[pl.DataFrame] = []
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_runtime(
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> AsyncIterator[None]:
|
|
||||||
'''
|
|
||||||
Yield a runtime-free command context.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
'''
|
|
||||||
Record optional durable writes.
|
|
||||||
|
|
||||||
'''
|
|
||||||
async def write_ohlcv(
|
|
||||||
self,
|
|
||||||
fqme: str,
|
|
||||||
ohlcv: pl.DataFrame,
|
|
||||||
timeframe: int,
|
|
||||||
) -> Path:
|
|
||||||
'''
|
|
||||||
Persist the frame for the command's read-back step.
|
|
||||||
|
|
||||||
'''
|
|
||||||
writes.append(ohlcv)
|
|
||||||
path = tmp_path / 'frame.parquet'
|
|
||||||
ohlcv.write_parquet(path)
|
|
||||||
return path
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_storage(
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> AsyncIterator[tuple[SimpleNamespace, Client]]:
|
|
||||||
'''
|
|
||||||
Yield the recording storage client.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield SimpleNamespace(), Client()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_annotations(
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> AsyncIterator[SimpleNamespace]:
|
|
||||||
'''
|
|
||||||
Yield an inert annotation controller.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield SimpleNamespace()
|
|
||||||
|
|
||||||
def iter_shms(
|
|
||||||
fqme: str,
|
|
||||||
) -> Iterator[tuple[Path, SimpleNamespace, pl.DataFrame]]:
|
|
||||||
'''
|
|
||||||
Yield the one valid history snapshot.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield shmfile, shm, frame
|
|
||||||
|
|
||||||
async def markup_gaps(
|
|
||||||
fqme: str,
|
|
||||||
period_s: int,
|
|
||||||
actl: SimpleNamespace,
|
|
||||||
new_df: pl.DataFrame,
|
|
||||||
step_gaps: pl.DataFrame,
|
|
||||||
) -> dict:
|
|
||||||
'''
|
|
||||||
Capture the frame used for non-mutating markup.
|
|
||||||
|
|
||||||
'''
|
|
||||||
markups.append(new_df)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def pause() -> None:
|
|
||||||
'''
|
|
||||||
Replace the command's final interactive pause.
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
storage_cli,
|
|
||||||
'open_piker_runtime',
|
|
||||||
open_runtime,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
storage_cli,
|
|
||||||
'open_storage_client',
|
|
||||||
open_storage,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(tsp, 'iter_dfs_from_shms', iter_shms)
|
|
||||||
monkeypatch.setattr(tsp._annotate, 'markup_gaps', markup_gaps)
|
|
||||||
monkeypatch.setattr(storage_cli.tractor, 'pause', pause)
|
|
||||||
|
|
||||||
from piker.ui import _remote_ctl
|
|
||||||
monkeypatch.setattr(
|
|
||||||
_remote_ctl,
|
|
||||||
'open_annot_ctl',
|
|
||||||
open_annotations,
|
|
||||||
)
|
|
||||||
|
|
||||||
shm_cmd(
|
|
||||||
'x.test',
|
|
||||||
write_parquet=True,
|
|
||||||
reload_parquet_to_shm=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert len(writes) == 1
|
|
||||||
assert len(markups) == 1
|
|
||||||
assert markups[0]['time'].to_list() == [60, 120, 240]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -368,7 +217,7 @@ def test_shm_write_without_reload_uses_deduped_markup_frame(
|
||||||
np.array([60, 60]),
|
np.array([60, 60]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_shm_skips_frame_without_positive_cadence(
|
def test_ldshm_skips_frame_without_positive_cadence(
|
||||||
times: np.ndarray,
|
times: np.ndarray,
|
||||||
) -> None:
|
) -> None:
|
||||||
'''
|
'''
|
||||||
|
|
@ -378,182 +227,9 @@ def test_shm_skips_frame_without_positive_cadence(
|
||||||
undefined for short buffers. Fully unpublished, one-row, and
|
undefined for short buffers. Fully unpublished, one-row, and
|
||||||
duplicate-only frames could therefore fail before the command's
|
duplicate-only frames could therefore fail before the command's
|
||||||
guard. Exercise each shape and prove inspection
|
guard. Exercise each shape and prove inspection
|
||||||
returns no cadence, which directs ``shm`` to skip the buffer.
|
returns no cadence, which directs ``ldshm`` to skip the buffer.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
period_s, _ = _shm_period_and_invalid_count(times)
|
period_s, _ = _shm_period_and_invalid_count(times)
|
||||||
|
|
||||||
assert period_s is None
|
assert period_s is None
|
||||||
|
|
||||||
|
|
||||||
def test_shm_buffer_discovery_uses_exact_identities(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
SHM discovery must not mix substring matches or index companions.
|
|
||||||
|
|
||||||
``shm`` globbed ``*fqme*`` and could process stale and
|
|
||||||
active objects for another instrument whose name merely contained
|
|
||||||
requested text. Create two generations, both OHLCV kinds, index
|
|
||||||
companions, and a substring collision. Prove discovery returns
|
|
||||||
exact parsed identities in deterministic filename order.
|
|
||||||
|
|
||||||
'''
|
|
||||||
names = [
|
|
||||||
'piker.datad[bbbbbbbb-bbbb-bb].qqq.nasdaq.ib.rt',
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.hist',
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.hist_first',
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].myqqq.nasdaq.ib.rt',
|
|
||||||
'unrelated.qqq.nasdaq.ib.rt',
|
|
||||||
]
|
|
||||||
for name in names:
|
|
||||||
(tmp_path / name).touch()
|
|
||||||
|
|
||||||
refs = list(tsp.iter_shm_buffer_refs(
|
|
||||||
fqme='qqq.nasdaq.ib',
|
|
||||||
shmdir=tmp_path,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert [ref.path.name for ref in refs] == [
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.hist',
|
|
||||||
'piker.datad[bbbbbbbb-bbbb-bb].qqq.nasdaq.ib.rt',
|
|
||||||
]
|
|
||||||
assert [
|
|
||||||
(ref.service, ref.generation, ref.kind)
|
|
||||||
for ref in refs
|
|
||||||
] == [
|
|
||||||
('datad', 'aaaaaaaa-aaaa-aa', 'hist'),
|
|
||||||
('datad', 'bbbbbbbb-bbbb-bb', 'rt'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_summary_reports_invalid_rows_and_ranked_gaps() -> None:
|
|
||||||
'''
|
|
||||||
Read-only SHM diagnostics must preserve the live triage evidence.
|
|
||||||
|
|
||||||
The QQQ investigation needed cadence, invalid bounds, and largest
|
|
||||||
timestamp jumps without mutation. Build one zero row and two gaps;
|
|
||||||
prove summary excludes invalid timestamps and ranks durations.
|
|
||||||
|
|
||||||
'''
|
|
||||||
frame = np.zeros(
|
|
||||||
5,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
frame['index'] = np.arange(100, 105)
|
|
||||||
frame['time'] = [1, 0, 3, 4, 10]
|
|
||||||
ref = tsp.ShmBufferRef(
|
|
||||||
service='datad',
|
|
||||||
generation='aaaaaaaa-aaaa-aa',
|
|
||||||
fqme='qqq.nasdaq.ib',
|
|
||||||
kind='rt',
|
|
||||||
path=Path(
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.rt'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
report = _summarize_shm_frame(ref, frame, max_gaps=2)
|
|
||||||
|
|
||||||
assert report['period_s'] == 1
|
|
||||||
assert report['observed_period_s'] == 1
|
|
||||||
assert report['invalid_count'] == 1
|
|
||||||
assert report['invalid_index_bounds'] == [101, 101]
|
|
||||||
assert report['gap_count'] == 2
|
|
||||||
assert [
|
|
||||||
gap['delta_s']
|
|
||||||
for gap in report['largest_gaps']
|
|
||||||
] == [6, 2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_summary_reports_ordering_corruption() -> None:
|
|
||||||
'''
|
|
||||||
Duplicate and reversed timestamps remain visible in diagnostics.
|
|
||||||
|
|
||||||
Positive-gap detection considers both defects gap-free. Build
|
|
||||||
one exact RT snapshot with a duplicate and reversal and prove the
|
|
||||||
read-only report distinguishes both without classifying values as
|
|
||||||
unpublished slots.
|
|
||||||
|
|
||||||
'''
|
|
||||||
frame = np.zeros(
|
|
||||||
5,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
frame['index'] = np.arange(5)
|
|
||||||
frame['time'] = [1, 2, 2, 1, 3]
|
|
||||||
ref = tsp.ShmBufferRef(
|
|
||||||
service='datad',
|
|
||||||
generation='aaaaaaaa-aaaa-aa',
|
|
||||||
fqme='qqq.nasdaq.ib',
|
|
||||||
kind='rt',
|
|
||||||
path=Path(
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.rt'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
report = _summarize_shm_frame(ref, frame, max_gaps=2)
|
|
||||||
|
|
||||||
assert report['invalid_count'] == 0
|
|
||||||
assert report['duplicate_step_count'] == 1
|
|
||||||
assert report['reversed_step_count'] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_dtype_resolution_matches_allocator_layout(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Inspector actors must mirror history allocation dtype lookup.
|
|
||||||
|
|
||||||
Allocation reads ``_ohlc_dtype`` from the package; IB does not
|
|
||||||
export its API-internal extended dtype there.
|
|
||||||
Create four production-shaped canonical rows and prove inspection
|
|
||||||
resolves the same layout without searching unrelated submodules.
|
|
||||||
|
|
||||||
'''
|
|
||||||
path = tmp_path / (
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.rt'
|
|
||||||
)
|
|
||||||
path.write_bytes(b'\0' * (4 * 56))
|
|
||||||
ref = tsp.ShmBufferRef(
|
|
||||||
service='datad',
|
|
||||||
generation='aaaaaaaa-aaaa-aa',
|
|
||||||
fqme='qqq.nasdaq.ib',
|
|
||||||
kind='rt',
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
|
|
||||||
dtype = tsp.resolve_shm_buffer_dtype(ref, size=4)
|
|
||||||
|
|
||||||
assert dtype.itemsize == 56
|
|
||||||
assert dtype.names == (
|
|
||||||
'index',
|
|
||||||
'time',
|
|
||||||
'open',
|
|
||||||
'high',
|
|
||||||
'low',
|
|
||||||
'close',
|
|
||||||
'volume',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'name',
|
|
||||||
[
|
|
||||||
'piker..datad[aaaaaaaa-aaaa-aa].x.test.rt',
|
|
||||||
'piker.datad[short].x.test.rt',
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].x\\test.rt',
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].x[y].test.rt',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_shm_parser_rejects_forged_identities(
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
SHM selectors must accept only generated Piker identities.
|
|
||||||
|
|
||||||
Reject malformed service, generation, and FQME components so a
|
|
||||||
same-user object that merely resembles OHLCV can not become an
|
|
||||||
``shm --shm-name`` target.
|
|
||||||
|
|
||||||
'''
|
|
||||||
assert tsp.parse_shm_buffer_path(Path(name)) is None
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,7 @@ import trio
|
||||||
|
|
||||||
from piker import tsp
|
from piker import tsp
|
||||||
from piker.data._source import def_iohlcv_fields
|
from piker.data._source import def_iohlcv_fields
|
||||||
from piker.storage.nativedb import (
|
from piker.storage.nativedb import NativeStorageClient
|
||||||
iter_native_series,
|
|
||||||
NativeStorageClient,
|
|
||||||
parse_ohlcv_parquet_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mk_ohlcv(
|
def mk_ohlcv(
|
||||||
|
|
@ -544,96 +540,8 @@ def test_index_files_ignores_sidecar_files(
|
||||||
|
|
||||||
index = client.index_files()
|
index = client.index_files()
|
||||||
|
|
||||||
assert list(index) == [('x.test', 60)]
|
assert list(index) == ['x.test']
|
||||||
ref = index[('x.test', 60)]
|
assert index['x.test']['period'] == 60
|
||||||
assert ref.fqme == 'x.test'
|
|
||||||
assert ref.period_s == 60
|
|
||||||
|
|
||||||
|
|
||||||
def test_index_files_preserves_every_series_timeframe(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
NativeDB discovery must identify FQME and timeframe together.
|
|
||||||
|
|
||||||
The old index keyed only by FQME, so filesystem iteration silently
|
|
||||||
discarded either the 1s or 60s file. Write both periods plus another
|
|
||||||
instrument, rebuild the index, and prove exact series remain stable
|
|
||||||
while compatibility key listing still returns unique FQMEs.
|
|
||||||
|
|
||||||
'''
|
|
||||||
client = NativeStorageClient(tmp_path)
|
|
||||||
run(client.write_ohlcv('x.test', mk_ohlcv((1, 2)), 1))
|
|
||||||
run(client.write_ohlcv('x.test', mk_ohlcv((60,)), 60))
|
|
||||||
run(client.write_ohlcv('y.test', mk_ohlcv((60,)), 60))
|
|
||||||
|
|
||||||
index = client.index_files()
|
|
||||||
keys = trio.run(client.list_keys)
|
|
||||||
series = trio.run(client.list_series)
|
|
||||||
|
|
||||||
assert list(index) == [
|
|
||||||
('x.test', 1),
|
|
||||||
('x.test', 60),
|
|
||||||
('y.test', 60),
|
|
||||||
]
|
|
||||||
assert keys == ['x.test', 'y.test']
|
|
||||||
assert [
|
|
||||||
(ref.fqme, ref.period_s)
|
|
||||||
for ref in series
|
|
||||||
] == list(index)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'name',
|
|
||||||
[
|
|
||||||
'x.test.ohlcv0s.parquet',
|
|
||||||
'x.test.ohlcv01s.parquet',
|
|
||||||
'x.test.ohlcv²s.parquet',
|
|
||||||
'x.test.ohlcv1.parquet',
|
|
||||||
'x.test.ohlcv1s.parquet.tmp',
|
|
||||||
'x.test.parquet',
|
|
||||||
'.ohlcv1s.parquet',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_native_series_parser_rejects_noncanonical_names(
|
|
||||||
tmp_path: Path,
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Malformed files must not become selectable storage identities.
|
|
||||||
|
|
||||||
NativeDB directories can contain crash files, legacy sidecars, and
|
|
||||||
unrelated Parquet. Exercise ambiguous period and suffix forms and
|
|
||||||
prove parser and directory discovery ignore them deterministically.
|
|
||||||
|
|
||||||
'''
|
|
||||||
path: Path = tmp_path / name
|
|
||||||
path.touch()
|
|
||||||
|
|
||||||
assert parse_ohlcv_parquet_path(path) is None
|
|
||||||
assert list(iter_native_series(tmp_path)) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_native_series_parser_round_trips_dotted_fqme(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Dotted market identities must survive canonical filename parsing.
|
|
||||||
|
|
||||||
The legacy parser split on every dot and failed for ordinary FQMEs.
|
|
||||||
Parse a futures-style identity and prove every selector field is
|
|
||||||
preserved exactly.
|
|
||||||
|
|
||||||
'''
|
|
||||||
path = tmp_path / 'mnq.cme.20260918.ib.ohlcv1s.parquet'
|
|
||||||
path.touch()
|
|
||||||
|
|
||||||
ref = parse_ohlcv_parquet_path(path)
|
|
||||||
|
|
||||||
assert ref is not None
|
|
||||||
assert ref.fqme == 'mnq.cme.20260918.ib'
|
|
||||||
assert ref.period_s == 1
|
|
||||||
assert ref.path == path
|
|
||||||
|
|
||||||
|
|
||||||
def test_writes_create_no_lock_sidecars(
|
def test_writes_create_no_lock_sidecars(
|
||||||
|
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
'''
|
|
||||||
Storage command UX regressions.
|
|
||||||
|
|
||||||
'''
|
|
||||||
from collections.abc import (
|
|
||||||
AsyncIterator,
|
|
||||||
Iterator,
|
|
||||||
)
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from piker import tsp
|
|
||||||
from piker import config
|
|
||||||
from piker.data import def_iohlcv_fields
|
|
||||||
from piker.storage import cli as storage_cli
|
|
||||||
from piker.storage.cli import store
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_group_shows_help_without_arguments() -> None:
|
|
||||||
'''
|
|
||||||
Bare ``piker store`` must present its command map immediately.
|
|
||||||
|
|
||||||
The Typer group emitted only a missing-command error and
|
|
||||||
required another ``--help`` invocation. Invoke the group
|
|
||||||
without arguments and prove complete help replaces that error.
|
|
||||||
|
|
||||||
'''
|
|
||||||
result = CliRunner().invoke(store, [])
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'Usage:' in result.output
|
|
||||||
assert 'Missing command' not in result.output
|
|
||||||
for command in (
|
|
||||||
'anal',
|
|
||||||
'audit',
|
|
||||||
'delete',
|
|
||||||
'ls',
|
|
||||||
'series',
|
|
||||||
'shm',
|
|
||||||
):
|
|
||||||
assert command in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_commands_show_help_without_arguments() -> None:
|
|
||||||
'''
|
|
||||||
Bare endpoints must not open runtimes or report missing args.
|
|
||||||
|
|
||||||
Every endpoint is discoverable by typing its name once. Exercise
|
|
||||||
required-input commands and prove help rendering exits before any
|
|
||||||
callback can run.
|
|
||||||
|
|
||||||
'''
|
|
||||||
runner = CliRunner()
|
|
||||||
for command in (
|
|
||||||
'anal',
|
|
||||||
'audit',
|
|
||||||
'delete',
|
|
||||||
'shm',
|
|
||||||
):
|
|
||||||
result = runner.invoke(store, [command])
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'Usage:' in result.output
|
|
||||||
assert 'Missing argument' not in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_series_lists_exact_native_periods(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Durable listing must expose separate 1s and 60s identities.
|
|
||||||
|
|
||||||
The old FQME-only index hid one timeframe. Create canonical files
|
|
||||||
without storage or actor services, invoke the JSON endpoint,
|
|
||||||
and prove both exact paths and periods are returned.
|
|
||||||
|
|
||||||
'''
|
|
||||||
nativedb: Path = tmp_path / 'nativedb'
|
|
||||||
nativedb.mkdir()
|
|
||||||
one = nativedb / 'qqq.nasdaq.ib.ohlcv1s.parquet'
|
|
||||||
sixty = nativedb / 'qqq.nasdaq.ib.ohlcv60s.parquet'
|
|
||||||
three_hundred = nativedb / (
|
|
||||||
'qqq.nasdaq.ib.ohlcv300s.parquet'
|
|
||||||
)
|
|
||||||
one.write_bytes(b'1')
|
|
||||||
sixty.write_bytes(b'60')
|
|
||||||
three_hundred.write_bytes(b'300')
|
|
||||||
monkeypatch.setattr(config, 'get_conf_dir', lambda: tmp_path)
|
|
||||||
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
store,
|
|
||||||
['series', '--json'],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
payload = json.loads(result.output)
|
|
||||||
assert [item['period_s'] for item in payload] == [1, 60, 300]
|
|
||||||
assert [item['path'] for item in payload] == [
|
|
||||||
str(one),
|
|
||||||
str(sixty),
|
|
||||||
str(three_hundred),
|
|
||||||
]
|
|
||||||
|
|
||||||
compact = CliRunner().invoke(store, ['ls'])
|
|
||||||
assert compact.exit_code == 0
|
|
||||||
assert 'qqq.nasdaq.ib' in compact.output
|
|
||||||
assert '1s, 60s, 300s' in compact.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_reload_requires_explicit_persistence() -> None:
|
|
||||||
'''
|
|
||||||
SHM reload must not silently opt into durable persistence.
|
|
||||||
|
|
||||||
Reload reads back the Parquet produced by the repair path, so the
|
|
||||||
reload flag alone has no valid input and must not open runtime or
|
|
||||||
storage services. Invoke that invalid combination and prove CLI
|
|
||||||
validation rejects it before inspecting an FQME.
|
|
||||||
|
|
||||||
'''
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
store,
|
|
||||||
['shm', 'qqq.nasdaq.ib', '--reload-parquet-to-shm'],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'requires --write-parquet' in result.output
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
'option',
|
|
||||||
[
|
|
||||||
['--json'],
|
|
||||||
['--max-gaps', '10'],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_shm_repair_rejects_read_only_formatting(
|
|
||||||
option: list[str],
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
Repair mode must not silently ignore read-only output options.
|
|
||||||
|
|
||||||
Combining the old repair workflow with the new inspector made
|
|
||||||
``--json`` and an explicitly default-valued ``--max-gaps``
|
|
||||||
appeared accepted even though repair produced logs and entered an
|
|
||||||
interactive pause. Invoke each conflicting option and prove
|
|
||||||
validation stops before runtime or storage can be opened.
|
|
||||||
|
|
||||||
'''
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
store,
|
|
||||||
[
|
|
||||||
'shm',
|
|
||||||
'qqq.nasdaq.ib',
|
|
||||||
'--write-parquet',
|
|
||||||
*option,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'apply only to read-only output' in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_ls_has_description_in_store_help() -> None:
|
|
||||||
'''
|
|
||||||
Compact NativeDB discovery must explain itself in group help.
|
|
||||||
|
|
||||||
The backend-oriented ``ls`` callback had no docstring, leaving
|
|
||||||
its command-map description blank. Render the parent help and
|
|
||||||
prove the compact series purpose is visible without opening the
|
|
||||||
command.
|
|
||||||
|
|
||||||
'''
|
|
||||||
result = CliRunner().invoke(store, [])
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'List NativeDB FQMEs' in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_endpoint_reports_immutable_snapshot(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
SHM diagnostics report exact buffers without storage mutation.
|
|
||||||
|
|
||||||
Build one RT identity and snapshot, then replace runtime
|
|
||||||
attachment with fakes. Invoke JSON output and prove it carries
|
|
||||||
generation, cadence, and invalid-slot
|
|
||||||
evidence without opening a storage client or writer.
|
|
||||||
|
|
||||||
'''
|
|
||||||
path = Path(
|
|
||||||
'piker.datad[aaaaaaaa-aaaa-aa].qqq.nasdaq.ib.rt'
|
|
||||||
)
|
|
||||||
ref = tsp.ShmBufferRef(
|
|
||||||
service='datad',
|
|
||||||
generation='aaaaaaaa-aaaa-aa',
|
|
||||||
fqme='qqq.nasdaq.ib',
|
|
||||||
kind='rt',
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
frame = np.zeros(
|
|
||||||
4,
|
|
||||||
dtype=np.dtype(def_iohlcv_fields),
|
|
||||||
)
|
|
||||||
frame['index'] = np.arange(4)
|
|
||||||
frame['time'] = [1, 0, 3, 4]
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def open_runtime(
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> AsyncIterator[None]:
|
|
||||||
'''
|
|
||||||
Yield a runtime-free diagnostic context.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield
|
|
||||||
|
|
||||||
def iter_refs(
|
|
||||||
fqme: str|None = None,
|
|
||||||
) -> Iterator[tsp.ShmBufferRef]:
|
|
||||||
'''
|
|
||||||
Yield the selected exact SHM identity.
|
|
||||||
|
|
||||||
'''
|
|
||||||
yield ref
|
|
||||||
|
|
||||||
def iter_frames(
|
|
||||||
fqme: str,
|
|
||||||
shm_name: str|None = None,
|
|
||||||
refs: list[tsp.ShmBufferRef]|None = None,
|
|
||||||
) -> Iterator[tuple[Path, SimpleNamespace, None]]:
|
|
||||||
'''
|
|
||||||
Yield an immutable snapshot through the attachment interface.
|
|
||||||
|
|
||||||
'''
|
|
||||||
shm = SimpleNamespace(array=frame)
|
|
||||||
yield path, shm, None
|
|
||||||
|
|
||||||
def fail_storage(*args: Any, **kwargs: Any) -> None:
|
|
||||||
'''
|
|
||||||
Fail if read-only SHM inspection reaches storage.
|
|
||||||
|
|
||||||
'''
|
|
||||||
raise AssertionError('SHM diagnostics opened storage')
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
storage_cli,
|
|
||||||
'open_piker_runtime',
|
|
||||||
open_runtime,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
storage_cli,
|
|
||||||
'open_storage_client',
|
|
||||||
fail_storage,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(tsp, 'iter_shm_buffer_refs', iter_refs)
|
|
||||||
monkeypatch.setattr(tsp, 'iter_dfs_from_shms', iter_frames)
|
|
||||||
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
store,
|
|
||||||
['shm', 'qqq.nasdaq.ib', '--json'],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
payload = json.loads(result.output)
|
|
||||||
assert len(payload) == 1
|
|
||||||
assert payload[0]['generation'] == 'aaaaaaaa-aaaa-aa'
|
|
||||||
assert payload[0]['period_s'] == 1
|
|
||||||
assert payload[0]['invalid_count'] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_shm_rejects_unknown_exact_name_before_runtime(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
A stale exact SHM selection must fail before opening services.
|
|
||||||
|
|
||||||
The old no-match path paused after opening runtime and storage
|
|
||||||
setup. Return no identity, install a runtime fail-spy,
|
|
||||||
and prove exact selection exits immediately
|
|
||||||
with a deterministic error.
|
|
||||||
|
|
||||||
'''
|
|
||||||
def iter_refs(
|
|
||||||
fqme: str|None = None,
|
|
||||||
) -> Iterator[tsp.ShmBufferRef]:
|
|
||||||
'''
|
|
||||||
Yield no matching SHM identities.
|
|
||||||
|
|
||||||
'''
|
|
||||||
return iter(())
|
|
||||||
|
|
||||||
def fail_runtime(*args: Any, **kwargs: Any) -> None:
|
|
||||||
'''
|
|
||||||
Fail if no-match handling opens a runtime.
|
|
||||||
|
|
||||||
'''
|
|
||||||
raise AssertionError('unknown SHM opened runtime')
|
|
||||||
|
|
||||||
monkeypatch.setattr(tsp, 'iter_shm_buffer_refs', iter_refs)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
storage_cli,
|
|
||||||
'open_piker_runtime',
|
|
||||||
fail_runtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
store,
|
|
||||||
[
|
|
||||||
'shm',
|
|
||||||
'qqq.nasdaq.ib',
|
|
||||||
'--shm-name',
|
|
||||||
'missing',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert 'No exact OHLCV SHM buffer' in result.output
|
|
||||||
Loading…
Reference in New Issue