Skip to content

fix(hyperliquid): dedup userFills to prevent position/PnL double-count - #18

Open
jenish-25 wants to merge 3 commits into
bulletxyz:masterfrom
jenish-25:fix/hl-fill-dedup
Open

fix(hyperliquid): dedup userFills to prevent position/PnL double-count#18
jenish-25 wants to merge 3 commits into
bulletxyz:masterfrom
jenish-25:fix/hl-fill-dedup

Conversation

@jenish-25

@jenish-25 jenish-25 commented Jul 2, 2026

Copy link
Copy Markdown

Problem

The Hyperliquid adapter double-counts fills, corrupting position and realized-PnL tracking. Message::UserFills emitted a Trade for every fill with no guard, but Hyperliquid's userFills subscription sends a historical snapshot (is_snapshot == true) on the initial subscribe and re-sends it on every transparent reconnect. Since Trade is the only canonical source of position/PnL:

  • Startup: inventory is seeded from REST get_positions(), then the snapshot replays those historical fills on top → position ~doubles + phantom PnL.
  • Reconnect: the snapshot replays recent fills again → more double-counting (reconnect only reconciles open orders, never re-seeds inventory).

The Bullet adapter already guards this via a RecentIds dedup keyed on trade_id; Hyperliquid didn't. Fixes #17.

Fix

  • Lift Bullet's RecentIds FIFO dedup set into bb_core::helpers::RecentIds and use it from both adapters (removes duplication).
  • Guard the HL fill path in a pure, unit-testable fills_to_emit:
    • record the initial snapshot's tids but don't emit them (already in the REST seed);
    • tid-dedup everything after — drops reconnect replays while still surfacing a genuinely new fill that landed during a disconnect gap.

Behavior

Scenario Behavior
Initial snapshot (is_snapshot, first message) recorded, not emitted
Live fill (new tid) emitted once
Reconnect snapshot replay (seen tid) dropped
Fill during disconnect gap (new tid in a later snapshot) emitted once
Fill with no tid emitted (nothing to dedup on)

Tests

  • Moved RecentIds' dedup + eviction tests to bb-core.
  • New fills_to_emit unit tests: initial-snapshot suppression, live-fill-once, reconnect-replay-dropped + gap-fill-emitted, other-coin filtered.
  • cargo test (workspace), cargo clippy --all-targets -- -D warnings, and cargo +nightly fmt --check all pass.

Out of scope

Re-seeding inventory from get_positions() on reconnect would be a further robustness improvement (strategy-layer, larger) — not needed for this correctness fix. Happy to follow up if you'd prefer that direction.


Summary by cubic

Deduplicates Hyperliquid userFills to stop position and realized PnL from being double-counted on startup and reconnects. Extracts a shared FIFO RecentIds helper to bb-core, clamps its capacity, and uses it in both adapters.

  • Bug Fixes

    • Suppress the first snapshot: record its tids, do not emit.
    • Dedup by trade_id after that to drop reconnect replays; emit gap fills once and fills without tid.
    • Clamp RecentIds capacity to >= 1 so cap = 0 can’t disable dedup.
    • Added unit tests for fills_to_emit scenarios.
  • Refactors

    • Moved RecentIds to bb-core::helpers and reused in bullet and hyperliquid.
    • Set MAX_SEEN_TRADE_IDS = 8192 to bound memory while covering reconnect windows.

Written for commit 7c4c4f3. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 4 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/bb-core/src/helpers/recent_ids.rs
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.

Hyperliquid userFills double-counts position/PnL (snapshot + reconnect replays)

1 participant