Skip to content

Fix 23 adversarial-review findings, Hyperliquid socket churn, and health honesty - #10

Merged
Co-Messi merged 28 commits into
mainfrom
fix/roast-and-launch-blockers
Sep 19, 2026
Merged

Co-Messi merged 28 commits into
mainfrom
fix/roast-and-launch-blockers

Conversation

@Co-Messi

Copy link
Copy Markdown
Owner

Summary

Fixes all 23 findings from an adversarial code review (3 Critical, 7 High, 13 Medium), plus 8 more from a pre-merge review of that work, plus a Hyperliquid socket bug found along the way. Each commit names the finding ID it fixes, so you can review one at a time.

The review's main point: the previous hardening pass (#2) added safe APIs and tests but never changed the code that calls them. venue_freshness(), get_cumulative_cvd() and WalletProfile.confidence were built, tested and documented, and nothing used them. The dashboards, API and health monitor all kept reading the old unsafe path. For every fix here, the callers were changed too, and the pre-merge review checked each one.

Critical

  • C1: every tracked wallet was labelled "smart money". Below 200 qualified wallets, which is the normal state of a fresh install, rank_all() could never assign the average tier. Tiers are now proportional, and wallet confidence is shown next to every tier.
  • C2: any website could read the local API. The loopback REST API sent Access-Control-Allow-Origin: * with no auth, so any page you visited could fetch whale positions and wallet addresses. It never sends a wildcard now, a new Host-header check blocks DNS-rebinding requests, and REST and WebSocket use one origin allowlist.
  • C3: a feed that connected but sent no data went unnoticed. Each venue now has a status: ok, connecting, silent, frozen, stale, disconnected or partial, each with a reason. It shows in /v1/health, both CVD dashboards, the health monitor and the status badge.

Found along the way: Hyperliquid order flow never worked

DEFAULT_SYMBOLS includes PEPE, BONK and FLOKI. Hyperliquid lists them as kPEPE, kBONK and kFLOKI (checked against the live meta API). One trades subscription for an unlisted coin closes the socket with code 1006 and no error message. So the single 50-symbol socket closed 0.6s after every connect: 107 reconnects in 75s on main, while it still reported "connected". Subscriptions are now checked against the live symbol list and split across sockets of at most 8 symbols.

Note: 802e441's commit message has the wrong cause (it guesses a subscription cap). 9f2b3b1 corrects it in the code, comments and its own commit message.

Binance

From some regions, wss://fstream.binance.com accepts the connection and then sends nothing, while Binance REST still answers. That's a regional block, not a bug in this code. The bug was that we logged "Connected, streaming 15 symbols" and said nothing after that. It now shows as silent with the likely cause, and the README says so.

Pre-merge review fixes

  • B1 (blocking): /v1/health would have stayed degraded on any install left running overnight. A full position-scan pass took at least 580s against a 600s staleness limit, even with no network delay. The in-memory address set was also never trimmed, so it grew forever. The limit is now worked out from the slowest possible pass (998s pass, 1497s limit), and the set is trimmed after each prune.
  • S1: a dead Hyperliquid socket was hidden as long as one other socket was alive. /v1/health now reports per-socket status, and the venue shows partial when some sockets are down.
  • S2–S7: removed an unused wallets table, made a failed shutdown flush log an error, fixed a timing-dependent test, raised dependency minimums to the versions actually tested, and guarded add_symbol() against changing the socket split mid-session.

Other notable changes

  • H1: requirements.lock pinned versions that pyproject.toml didn't allow, and CI's --no-deps install hid that. Fixed, and CI now runs pip check, installs from pyproject.toml in one job, and tests that the two files agree.
  • H6: SQLite writes moved off the event loop onto a writer thread. Writes are batched, a full queue drops writes and counts them, and every read waits for pending writes first.
  • M13: demo-data generation moved out of hub.py (1287 → 903 lines) and liquidation processing out of api_server.py, with no behaviour change. Existing attribute access still works, and a test checks it.

Test plan

  • pytest tests/ -q: 296 passed, 2 skipped, up from 166 on main
  • ruff check src/ tests/: clean
  • Fresh clone of this branch, new venv, pip install -r requirements.lock && pip install -e ., then pip check: clean
  • CI import checks: all 4 pass
  • run_api.py against live feeds for 115s: Hyperliquid ok, 7/7 sockets, 4117 trades, 141 liquidations; Binance silent with its reason; position scan not stale; 0 pending and 0 dropped writes
  • Clean shutdown on SIGTERM, with nothing left unwritten
  • CI on Ubuntu, Python 3.12 and 3.13

Known and left for later

  • /v1/health stays at warn wherever Binance is blocked. That's accurate, but don't point a monitor at it expecting ok.
  • DEFAULT_SYMBOLS still lists PEPE/BONK/FLOKI. They're now skipped with a warning, so the socket stays up. Renaming them to the k names would add three symbols; that's a separate change.
  • Three tests pass on main as well. They're labelled Control: / Guard: because they check that nothing broke, not that a bug was fixed.

🤖 Generated with Claude Code

Co-Messi and others added 26 commits September 15, 2026 16:49
…store failures loud

M1: `snapshots` and `paper_trades` had no writer anywhere in src/ (the
real paper-trade log lives in data/paper_trades.db with a different
schema). Stop creating them, drop the v2 migration that only touched
them, and add a v3 step that drops the legacy tables when empty (a
populated one is left alone and reported). `_run_migrations` now runs
only the steps above the DB's recorded version instead of every step on
every startup, so future migrations no longer have to be
idempotent-by-accident.

M10: `discovered_addresses` is declared in DataStore's versioned schema.
address_store write failures log at WARNING (were DEBUG); read failures
RAISE instead of returning an empty set, so an unreadable store can no
longer be mistaken for "no addresses yet" and silently re-discovered.
The retention COUNT/DELETE moves from every write into prune(), called
hourly by the hub, and the scanner persists only newly discovered
addresses instead of rewriting the whole set. DataStore is constructed
before PositionScanner so a corrupted DB reaches the quarantine path
before address_store opens it. Also fixes L2 (negative dropped count on
generator input).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rywhere a tier is shown

rank_all() used fixed "top 100 / bottom 100" boundaries, so with fewer
than 200 qualified wallets — the normal state of a fresh install — the
"average" branch was unreachable and every ranked wallet was labelled
smart or dumb (50 qualified -> all 50 "smart", including the worst).
Tiers are now the top/bottom 10% of the qualified population (capped at
100 each); below 10 qualified wallets everything is ranked but "average".

WalletProfile.confidence was computed and consumed by nothing. It now
travels with the label: SmartMoneySignal.wallet_confidence (set in
check_signals and the demo generator), wallets.confidence +
smart_money_signals.wallet_confidence columns (v3 migration,
save_wallet/load_wallets/get_signals), and a CONF column beside every
smart/dumb row plus each signal line in the smart-money panel.

Two tests in test_hardening.py asserted the buggy behaviour (a single
qualified wallet labelled "smart") and are corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…igin allowlist for REST and WS

On a loopback bind with no HYPERDATA_CORS_ORIGINS, _resolve_security
returned None and _make_cors_middleware(None) emitted
`Access-Control-Allow-Origin: *` on every response with no auth
installed — so any web page the user had open could fetch()
/v1/whales and /v1/positions/danger-zone and read the tracked wallet
addresses back. Loopback is not a boundary against the user's own
browser.

- CORS headers are now emitted only for an Origin in the explicit
  allowlist, on every bind. No allowlist -> no CORS headers (curl/SDK
  clients send no Origin and are unaffected).
- New first-position Host-guard middleware: on a loopback bind the Host
  header must itself be loopback (localhost / 127.x / [::1], with or
  without port), closing DNS rebinding for non-CORS clients too.
- M7: _resolve_security returns the same set on every bind and both the
  REST CORS middleware and the WebSocket Origin gate read it, so the two
  surfaces can no longer disagree (REST was wildcard-open while WS
  rejected every browser origin).
- README, .env.example, docs/DATA_INTEGRITY.md and the module docstring
  no longer describe wildcard CORS as the loopback default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nsumer

C3: the only warning for a half-dead order-flow feed was gated on
`venue_data_age(venue) != float("inf")`, which excluded exactly the venue
that had never delivered a byte — the confirmed-live Binance regional
block (handshake succeeds, zero frames, forever). Every consumer then
read the blended path: health_monitor called is_stale() (max of both
venues), cvd_dashboard/hub_panels rendered engine.cumulative_cvd as
"CVD", and /v1/orderflow returned the same sum. get_cumulative_cvd() had
no production callers.

OrderFlowEngine now tracks per-venue connection, frame, trade and
parse-error counters (VenueState) and classifies each venue as
ok / connecting / silent / frozen / stale / disconnected with a reason.
Consumers rewired:
- hub._update_feed_staleness: guard deleted; warns (debounced) for ANY
  non-ok venue incl. never-connected; orderflow_engine status gains
  'partial' when one venue is out; startup grace via status.started_at.
- health_monitor._check_freshness: order_flow_hyperliquid /
  order_flow_binance checks (warn when that venue is out, fail when all
  are); blended check names the contributing venues.
- api_server.handle_orderflow: cumulative_cvd_by_venue, venue_coverage,
  venues_contributing next to the combined figure. handle_health: the
  bare `except: pass` around venue_freshness() is gone (L7).
- cvd_dashboard.venue_cvd_text renders `CVD: +X [HL +a | BN silent]`;
  both build_price_bar and HubCVD use it; demo engines are labelled
  [DEMO] instead of being attributed to a venue.
- combined_dashboard badge: 'warn' -> ⚠ PARTIAL (was ✓ LIVE).
- README (CVD row, Binance data-source row) and docs/DATA_INTEGRITY.md
  document the statuses and the regional caveat.

H2: _handle_binance_trade stamped liveness BEFORE parsing and swallowed
parse errors with a bare `pass` ("fresh but frozen", zero log output),
and frames without `data` (acks, error envelopes) returned before any
bookkeeping. Frames are now counted first; liveness is stamped only by a
successfully parsed trade; parse failures increment a per-venue counter
and log a rate-limited WARNING with the exception; all of it is exposed
via venue_freshness() -> /v1/health. Same treatment for the HL handler.
The Binance loop now logs the exception type/message on error and
distinguishes a clean server close (L5).

M11: _start_component's on_ok set 'connected' on a start() that only
creates tasks. WS-driven feeds now start as 'connecting' and the
watchdog promotes them on first real data (trade / book / liquidation /
HLP snapshot). Status panel shows connecting/partial in yellow with the
state name. test_hardening asserted the old 'connected' and is updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r on warn; fix docs URL

`status = "ok" if overall in (None, "ok", "warn") else "degraded"` mapped
both "checks have not run yet" (the first ~45s of every live session)
and "warn" (BTC price unavailable, zero funding symbols, one order-flow
venue silent, ...) to top-level "ok", so a monitor keyed on status=="ok"
never fired for a genuinely degraded terminal.

Now: `initializing` (live mode, no verification result yet), `ok`,
`warn` (a health check warned or a feed is 'partial'), `degraded` (a
feed is stale/erroring, a cross-reference drifted, or a component failed
to start). Documented in the README endpoint table.

M12: the advertised docs URL pointed at a non-existent repository;
it now matches `git remote` (Co-Messi/HyperData-Terminal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`conn.execute("PRAGMA integrity_check")` never fetched its result. The
pragma does not raise — it returns `('ok',)` or a list of corruption
descriptions — so only damage severe enough to fail the open itself
("file is not a database") ever reached the quarantine path; a
page-corrupted file opened "successfully" and the app ran on it.

DataStore now runs `PRAGMA quick_check`, fetches the row, and raises
sqlite3.DatabaseError on anything but 'ok' into the existing
quarantine-and-recreate path. quick_check skips the index-consistency
scan so startup on a large DB stays fast. Tested by corrupting page 2 of
a real database file while leaving the header intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…otice when it doesn't

requirements.lock pinned rich==14.3.3 and pandas==3.0.1 while
pyproject.toml declared rich<14.0 and pandas<3.0. CI installed the lock
and then `pip install -e . --no-deps` — precisely the flag that stopped
pip from raising the conflict — so every green run validated a
dependency set that `pip install -e .` (the README's install path) could
not produce.

- pyproject.toml widens to rich<15 and pandas<4: the versions CI has
  actually been testing, and what a fresh `pip install -e .` resolves to
  today (rich 14.3.4 / pandas 3.0.5, `pip check` clean). requirements.txt
  mirrors the ranges.
- tests/test_dependency_lock.py asserts, via tomllib + packaging, that
  every lock pin satisfies its pyproject specifier, that the lock has no
  undeclared direct deps, that requirements.txt mirrors pyproject, and
  that CI installs with dependency resolution and runs `pip check`.
- CI: the lock leg installs `-e .` WITH dependencies and runs `pip check`;
  a new matrix leg installs via plain `pip install -e .` on 3.13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…everse-on-opposite flag

M2: `if self._db:` skipped the entire persist-first block when the trade
log was not open (never started, or already stopped) and apply_mutation()
ran anyway — the one path the docstring's "a trade that cannot be logged
is not executed" did not cover. `_db is None` is now a hard refusal
logged at ERROR. The existing accounting tests built traders with no DB
and relied on that hole; they now attach an in-memory log.

M3: an opposite-side signal silently closed the position and dropped the
strategy's directional intent until the next check_interval. The
behaviour is now an explicit constructor flag,
reverse_on_opposite_signal (default False, unchanged semantics —
test_close_realizes_pnl still holds). The default is logged at start()
and once at the first close-only close; with the flag, the close is
followed by a second, fully checked and logged trade opening the reverse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An editor/tool hook in the development environment writes <file>.bak next
to edited files; 'git add -A' swept them into the preceding commits. They
are exact copies of the pre-edit sources (no secrets). Removed from the
index and ignored going forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…IP WS cap; escape exchange/LLM strings in Rich output

M4: hub.stop() wrapped nine component shutdowns in bare `except: pass`.
They now run in a loop that logs each failure with logger.exception and
keeps stopping the remaining components (and the API server).

M5: _RateLimiter swept its whole dict on every request once it held >10k
keys and removed nothing while those keys were active — O(n) per request
exactly when the limiter mattered. Keys now live in an OrderedDict LRU
with O(1) eviction of the least recently seen key above MAX_TRACKED_KEYS.

M6: MAX_WS_CONNECTIONS was global only, so one client opening 10 sockets
locked everyone else out. A per-source-address cap
(MAX_WS_CONNECTIONS_PER_IP=3) is checked first; _WSClient records its
remote.

M9: exchange-supplied symbols reached Rich as bare `str` cells
(hub_panels x4, liquidation_stream, market_overview x3, whale_tracker x3)
and the LLM's `reason` reached console.print unescaped in paper_trader —
after apply_mutation() had already changed the books. A symbol like
"[/bold]" raised MarkupError inside the Live loop; "[bold red]X[/]"
silently restyled the table. All sites now wrap in Text() or
rich.markup.escape(). Regression test renders every affected panel and
table with both probes (the reviewer's "[bold" example is literal text
for Rich; an unmatched closing tag is what raises).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ancellable LLM transport, no refund on timeout

M8: `_compute_sharpe` was mean/std of DOLLAR closed PnL with no
annualisation, no time basis and no small-sample handling, so ten similar
$50 scalps produced a ratio that clamped to the +1.0 maximum and earned
the full GAMMA weight; `_compute_pnl_score` (log10/10) compressed
$1k..$1M into 0.30..0.60 so BETA's 0.40 nominal weight had ~0.12 of real
range; and ALPHA/BETA/GAMMA had no stated rationale.
- analyze_wallet collects per-trade RETURNS (closedPnl / fill notional);
  `_compute_risk_adjusted` is mean/std of those, multiplied by
  n/(n+20) so a handful of trades cannot max the component, and treats
  float-noise dispersion as zero. The persisted field keeps its
  `sharpe_ratio` name (DB column) and is documented as not a Sharpe.
- `_compute_pnl_score` saturates at $1M (log10/6): $1k->0.5, $1M->1.0.
- Weights documented as an ordinal preference (PnL > win rate > risk
  tie-breaker), composite bounded in [-0.65, 1]. Monotonicity, bounds
  and shrinkage are tested.

H3: evaluate() ran urllib in a single-worker ThreadPoolExecutor under
wait_for — a timeout abandoned the await but not the thread, so one
trickling response wedged every later evaluation forever — and the
timeout branch REFUNDED the hourly budget slot, letting a slow-but-billing
provider run at ~2x the nominal cap. The already-written aiohttp
_async_evaluate was dead code.
- _async_evaluate (cancellable) is now the only path under wait_for;
  the executor, _sync_evaluate and _sync_call are removed.
- Refund only on aiohttp.ClientConnectorError (never reached the
  provider). Timeouts, HTTP errors and parse failures keep their slot.
- In-flight guard: a tick arriving mid-evaluation is skipped, not queued.
- `reason` is bounded to MAX_REASON_CHARS.
- TestLLMRound2.test_transport_failure_refunds_budget_slot rewritten for
  the async transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sitions are shown

scan() fetched EVERY tracked address at 10 req/s, so cycle time grew
linearly with the address store — 50,000 addresses (the retention cap)
was an ~83-minute cycle behind a 15s scan_interval — and nothing
reported it: _position_scan_loop set position_scanner="connected" after
each success and the staleness watchdog never looked at the scanner, so
/v1/health, the whale tracker and the danger zone served hour-old
liquidation distances as live.

- Each cycle scans at most SCAN_ADDRESS_BUDGET (150 ≈ 15s) addresses,
  round-robin across cycles, and keeps a per-address position cache; a
  failed request keeps the previous entry instead of reading as "no
  positions". self.positions is rebuilt from the cache every cycle with
  current_price/distance_pct recomputed from fresh mids.
- TrackedPosition.scanned_at records when a position was actually
  fetched; PositionScanner gains last_scan_at, last_full_pass_at,
  scan_age_seconds(), oldest_position_age_seconds(), is_stale(),
  as_of() and freshness().
- Hub watchdog flips position_scanner to 'stale' (POSITION_STALE_AFTER
  = 600s); health monitor emits a position_scanner freshness check
  (warn before the first cycle, fail when stale); /v1/health carries
  position_scan {scan_age_seconds, oldest_position_age_seconds, ...};
  /v1/whales and /v1/positions/danger-zone carry as_of and
  scan_age_seconds; the whales panel subtitle shows scan age (red when
  stale). Demo positions are stamped so demo mode is not "stale".
- address_store.MAX_TRACKED_ADDRESSES is 3,000, derived from the
  budget: 20 cycles ≈ 10 min to revisit every address (tested).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r thread

_save_trade / _save_liquidation (hub callbacks invoked from inside the
WebSocket read loops) ran a blocking INSERT under a threading.Lock on the
asyncio event loop — at full Binance+HL trade rates, thousands of
synchronous disk writes per second on the thread running every feed, the
API server and the heartbeat. get_db_stats (two COUNT(*) scans) and
prune() (full-table DELETEs) ran on the same loop from the status tick.

- DataStore owns a daemon writer thread fed by a bounded deque. Every
  INSERT path (liquidations, sampled trades, smart-money signals, HLP
  trades/snapshots, funding, LSR, options) enqueues and returns; the
  writer applies batches under the connection lock with the existing
  50-event / COMMIT_INTERVAL commit cadence and a periodic wake so a
  trickle is never left uncommitted.
- Queue capped at WRITE_QUEUE_MAX (50k); overflow is dropped and counted
  (dropped_writes / write_queue_pending in get_db_stats, rate-limited
  WARNING).
- flush()/close()/prune()/every get_* drain the queue first (sequence
  numbers, bounded wait with a logged backlog), so a read right after a
  write still sees it and shutdown persists everything.
- Trade sampling counter moves under its own tiny lock; no I/O on the
  read path.
- Hub calls get_db_stats / store.prune / address_store.prune via
  asyncio.to_thread. (address_store's per-write COUNT/expiry already
  moved to prune() and the scanner already persists only new addresses
  in the M1/M10 commit.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ocessing from api_server.py

Mechanical extraction, no behaviour change:

- src/data_layer/hub_demo.py holds the nine demo-mode generators
  (466 lines moved verbatim, `self.` -> `hub.`). HyperDataHub keeps
  one-line `_demo_*` delegators so task names, call sites and tests are
  unchanged. hub.py: 1,287 -> 903 lines.
- src/data_layer/liquidation_processing.py holds LiquidationProcessor:
  per-exchange dedup, cascade detection/bypass, symbol cleanup, leverage
  estimation and the 60s stats log — the data-layer logic that lived in
  the REST server. HyperDataAPI constructs one in __init__ (replacing
  the lazy `__init_dedup` hasattr pattern), delegates
  _is_duplicate_liq/_check_cascade/_clean_symbol/_log_liq_stats, and
  exposes the historical attribute names (_liq_seen, _cascade_bypass,
  _cascade_bypass_started, _CASCADE_BYPASS_DURATION, ...) as views onto
  the processor so every existing caller and TestLiquidationDedup pass
  unmodified.

Equivalence evidence: the full pre-existing suite passes unchanged; the
new TestM13Extraction behavioural tests (demo generators populate the
same component state; the demo hub starts, produces data and stops) pass
against the pre-extraction commit and after it, while only the
structural assertions fail before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thread and PARTIAL badge

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lean WS close

Both found by the live end-to-end run of this branch, and both confirmed
pre-existing on 2f7338a (107 HL reconnects and one "database is locked"
in a 75s base run) — the C3 venue counters and the M10 WARNING-level
logging made them visible.

H6 follow-up: the writer held its SQLite transaction open between commits
(every 50 events / 5s), so under trade load the WAL write lock was held
for seconds at a time and address_store's second connection to the same
file starved on the millisecond gaps: "database is locked" despite a 10s
busy timeout, 57 discovered addresses not persisted. Every applied batch
is now its own committed transaction (WAL + synchronous=NORMAL: not an
fsync). _maybe_commit is gone.

L5: OrderFlowEngine._run_forever reset the backoff and looped with NO
sleep after a clean server-side close, so a venue that closes right
after the 50-symbol subscribe burst was reconnected ~1.5x/second (138
connects in 92s), tripping the venue's message rate limit and sustaining
the storm. A clean close now backs off (1s doubling to 60s) unless the
connection lived past STALE_AFTER_SECONDS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…of <=8

Root cause of the HL reconnect churn surfaced by the C3 venue counters
(107 connects in a 75s base run; "disconnected 35s ago, connects=3" in
the coordinator's verification). Measured live against
wss://api.hyperliquid.xyz/ws: a socket carrying 8 `trades` subscriptions
stays up indefinitely; the 10th subscription kills it with close code
1006 within ~0.2s, whether the subscriptions are sent in a burst or 1s
apart, and concurrent sockets from one IP with 8 each all stay up. The
engine subscribed all 50 DEFAULT_SYMBOLS on one socket, so every
connection died 0.6s after connecting — since forever. The old loop
slept 0s after a clean close and re-harvested ~0.6s of trades per
connect ~1.5x/second, which read as "connected"; the L5 backoff then
turned that into long gaps.

- HL symbols are partitioned into shards of HL_SUBSCRIPTIONS_PER_SOCKET
  (8), one reconnect loop + socket per shard (7 for the defaults).
- VenueState.connects counts every socket; the venue flips connected on
  the first shard up and disconnected only when the last one is gone.
- Short-lived clean closes back off to at most 15s (errors still 60s).
- OrderFlowEngine.force_reconnect() / hl_sockets_open replace the hub
  watchdog's reach into a single private `_ws`.
- docs/DATA_INTEGRITY.md documents the cap and the sharding.
- .gitignore: .venv-verify/ (coordinator's verification venv).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e; let stop() finish closing sessions

Corrects the previous commit's diagnosis. Per-symbol live probe: PEPE,
BONK and FLOKI in DEFAULT_SYMBOLS are not in Hyperliquid's `meta`
universe (HL lists them as kPEPE/kBONK/kFLOKI), and a `trades`
subscription for an unlisted coin closes the socket with 1006 and no
error message. Every other symbol keeps a socket up indefinitely. That —
not a ~10-subscription cap — is why the 50-symbol socket died 0.6s after
every connect (the 10th symbol in the earlier sweep was PEPE), and why
shards 1 (PEPE) and 4 (BONK, FLOKI) kept dying at 0.6s after sharding
while the other five never did (18 short-lived closes / 94s).

- Subscriptions are filtered against the universe (fetched once per
  hour through the shard's own session, serialised across shards). An
  unlisted symbol is skipped with ONE WARNING naming it and the alias HL
  uses. If the fetch fails, subscriptions go out unfiltered and the last
  good universe is kept — a REST hiccup must not darken the venue.
- Sharding stays, re-justified honestly: a coin delisted between
  universe refreshes takes down one shard, not the venue.
- stop(): closing a shard's socket makes its loop close its
  ClientSession in `finally`; stop() cancelled the loops while they were
  inside that close, leaving one "Unclosed client session" per healthy
  shard at exit (5, seen live). stop() now waits up to STOP_GRACE_SECONDS
  for the loops to unwind before cancelling, and the close is shielded.
- Constant comments, test docstrings and docs/DATA_INTEGRITY.md no
  longer state the wrong cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-sync tracked set after prune

POSITION_STALE_AFTER_SECONDS was a hardcoded 600s while a full round-robin
pass over the 3,000-address cap took >=580s at zero latency (150/cycle,
14s of batch sleeps + 15s scan_interval), so every real install read the
scanner as 'stale' and /v1/health as 'degraded' forever. The threshold is
now derived: worst-case healthy cycle (per-batch latency allowance, sleeps,
price/meta refresh, hub interval) x cycles over the in-memory bound, x1.5
margin (~25 min). The hub's default scan_interval comes from the same
constant and a longer one warns at construction.

The in-memory set was also never bounded: PositionScanner loaded it once
and only ever .update()d it; address_store.prune() trimmed the table but
never the set, so full-pass time was unbounded. PositionScanner.
resync_addresses() re-reads the store off the loop (keeping anything
discovered meanwhile) and the hub calls it right after each hourly prune;
the derivation bounds the set at the cap plus what discovery can add
between prunes.

Fixes the wrong 'bounds the tracked set to ~3,000 addresses' comment, the
address_store cap comment and the docs' 10-minute claim; staleness tests
use the constant instead of a hardcoded 700s. TestB1StalenessBudget pins
the arithmetic (fails on the old numbers) and the prune->re-sync path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…atus; fix the shard plan at start()

S1: last_hl_message_at was stamped by ANY shard's trades and the venue only
went 'disconnected' when no socket was left, so with one shard alive
Hyperliquid read `ok` however many were dark; hl_sockets_open reached
nothing but a log line. Each shard now carries a ShardState (connect time,
down-since, consecutive short-lived closes, idle). A shard is dark when its
socket has been gone past CONNECT_GRACE_SECONDS or when it is flapping —
its last HL_SHARD_FLAP_CLOSES sockets each died within STALE_AFTER_SECONDS
of connecting, the confirmed-live failure mode where a rejected
subscription closes the socket ~0.6s after every connect and the backoff
keeps each gap under the grace. Any dark shard turns the venue 'partial'
(trades flowing, symbols missing); venue_freshness() — hence /v1/health →
orderflow_venues.hyperliquid — carries sockets_open, sockets_expected,
shards_dark, shards_idle and dark_symbols. The hub flips orderflow_engine
to 'partial' (health 'warn'), the health monitor's per-venue check warns,
the CVD renderers keep the number and flag it, and contributing venues
include 'partial'.

S7: the shard->symbols plan is snapshotted at start(); _connect_and_listen
reads the snapshot instead of re-partitioning self.symbols on reconnect,
so add_symbol() (latent: its one call site cannot fire) can no longer
hand existing shards new symbols with no loop for the overflow; it warns
when called while running. hub.py's 'dynamically expanded after market
data loads' comment was false and is gone.

Nit: the idle branch (no listed symbols in a shard) slept HL_UNIVERSE_TTL
without checking _running, so stop() burned the full STOP_GRACE_SECONDS;
it now waits on a stop event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… nothing wrote

wallets.confidence was added by the v3 migration and written by
DataStore.save_wallet, but save_wallet/load_wallets had zero production
callers (only tests) and attach() never registered a wallet callback:
the column could only ever be 0.0. The `wallets` table itself never had a
writer in src/ — the same class of dead table M1 already removed.

Chosen fix: remove, not wire. SmartMoneyEngine keeps profiles in memory
and recomputes them from fills every session; loading persisted scores
(including sharpe_ratio, whose formula changed on this branch) into a
ranking that is rebuilt anyway would have been the dishonest option.
v4 drops `wallets` when empty and, like M1, keeps and reports a populated
one; v3 no longer touches it; save_wallet/load_wallets are gone. The
WalletProfile.sharpe_ratio comment no longer claims to be a persisted DB
column, which also closes the "stale rows carry incomparable values" nit:
there are no rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est failed pre-fix" docstring

The module docstring claimed every test failed against the pre-fix tree.
Three did not — test_non_empty_legacy_table_is_preserved,
test_with_db_the_same_trade_executes_and_is_logged and
test_composite_is_bounded — they are legitimate control cases pinning the
behaviour a fix must not break. They now say "Control:"/"Guard:" up
front, and the docstring explains the two kinds; nothing was deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t drop count is deterministic

test_queue_is_bounded_and_drops_are_counted waited for the writer to take
the first batch with a 5ms sleep loop against a 2s deadline and then
asserted dropped_writes == 4 exactly; under CI load the loop could time
out with the first item still queued, making the count 5. The writer is
now parked by a gate inside _apply, and "the first item is in flight" is
an Event the test waits on. 20/20 runs green locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…()/close()

flush() called _drain() and threw away its bool; close() then closed the
connection regardless, so a drain that timed out abandoned everything in
the write queue with a single WARNING on a logger run_api.py gives no
stdout handler. The drain timeout is now an ERROR naming the backlog;
flush() returns the drain result; close() returns False and logs at
ERROR — with the count — when the drain timed out or the writer did not
stop before the join timeout; hub.stop() repeats the failure in its own
shutdown log. New pending_writes() exposes the backlog.

Also fixes _drain's timeout being a default argument bound at definition
time, which made DRAIN_TIMEOUT_SECONDS impossible to tune at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ver tested

H1 widened the ranges to rich>=13.7,<15 and pandas>=2.2,<4, but CI runs
the lock (rich 14.3.3 / pandas 3.0.1) on both Pythons and the pyproject
leg resolves to the newest in range on 3.13 only; nothing exercises the
older floors. The lower bounds are now the pins, requirements.txt mirrors
them, and tests/test_dependency_lock.py asserts the floor IS the pin for
both packages so the ranges cannot be widened downward silently again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_db_stats() returned write_queue_pending and dropped_writes but no
/v1/health field carried them. HubStatus now holds both (refreshed with
the other DB stats every 30s) and /v1/health exposes them under a
`persistence` block alongside db_size_mb / events_persisted. Informational
only: a drop is a persistence loss, not a live-data fault, and the counter
never resets, so it does not gate the top-level status. Docs updated.

(The other nits landed with their finding: the idle-shard sleep with S1,
the last_full_pass_at docstring with B1, sharpe_ratio with S2.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f12a4d541

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +219 to +223
for addr, result in zip(batch, results):
if isinstance(result, list):
for p in result:
p.scanned_at = fetched_at
self._position_cache[addr] = result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Track successful scans for addresses with no positions

When an address successfully returns an empty position list, the cache stores [] without any freshness timestamp. If later requests for that address fail, the empty entry is retained, self.positions contains nothing for it, and last_scan_at still advances after every cycle; consequently oldest_position_age_seconds() returns zero and is_stale() remains false indefinitely. During a clearinghouse endpoint outage, wallets that were previously flat can therefore open positions that are never displayed while the scanner continues reporting healthy. Track the last successful fetch per address, including empty results, or avoid marking an all-failed cycle as fresh.

Useful? React with 👍 / 👎.

Comment on lines +192 to +195
finally:
with self._q_cond:
self._applied_seq += len(batch)
self._q_cond.notify_all()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not acknowledge a batch after _apply fails

If _apply() raises, such as from a full disk or SQLite commit/I/O failure, the batch has already been removed from _write_q but this finally block unconditionally marks every item as applied. The batch is neither retried nor added to dropped_writes, so _drain() can return true, the health endpoint can report zero pending/dropped writes, and shutdown can appear successful even though the batch was lost. Advance _applied_seq only after a successful durable commit, or requeue/count the failed batch.

Useful? React with 👍 / 👎.

Co-Messi and others added 2 commits September 19, 2026 17:33
pip-audit in CI flagged three advisories against aiohttp 3.14.1 that
were published after the lock was written. 3.14.3 fixes all three. It
satisfies the existing pyproject range (>=3.9.0,<4.0) and needs no
transitive changes; pip check, pip-audit, ruff and the full suite pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fixture's setup check assumed PRAGMA quick_check returns a non-ok
row for a smashed table page. That holds on macOS, but the SQLite on
GitHub's Ubuntu runners raises DatabaseError for the same file, so the
test failed during setup on every CI job.

DataStore already handles both: _check_integrity runs inside the
try/except sqlite3.DatabaseError that quarantines the file. Only the
test was too strict. It now accepts either a non-ok verdict or a
raised error, and the quarantine assertion is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Co-Messi
Co-Messi merged commit d2fb63a into main Sep 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant