Skip to content

fix(tui): stream real-time updates to Orders, Positions and Portfolio tabs#10

Merged
llcro merged 4 commits into
mainfrom
fix/orders-positions-realtime
Apr 27, 2026
Merged

fix(tui): stream real-time updates to Orders, Positions and Portfolio tabs#10
llcro merged 4 commits into
mainfrom
fix/orders-positions-realtime

Conversation

@llcro
Copy link
Copy Markdown
Collaborator

@llcro llcro commented Apr 27, 2026

Summary

Beta testers reported that Orders required a manual refresh to see fresh data, and Positions only updated mark price / P&L live while Size stayed frozen until r was pressed. Investigation surfaced the same class of bug on Portfolio (holdings stale between 30s REST refreshes).

Root cause: the UserStreamManager already subscribed to user.order, user.positions, and user.balance, and lib.rs already dispatched them as DataEvents, but:

  1. App::on_data forwarded events only to the active tab, so user-channel deltas to Orders/Positions/Portfolio were dropped whenever another tab was visible.
  2. OrdersTab, PositionsTab, and PortfolioTab had no match arms for their respective DataEvent variants, so even when active they silently discarded the WS updates.
  3. on_activate reset loaded on every tab switch, forcing a REST round-trip and a "loading..." flash.

Commits

  • df136d0 — Orders and Positions wiring. Broadcast OrdersUpdate / PositionsSnapshot / BalanceSnapshot to their owning tabs via TabKind lookup regardless of active tab. OrdersTab upserts by order_id and removes on terminal statuses (FILLED / CANCELED / CANCELLED / REJECTED / EXPIRED), sharing one parser between REST and WS paths. PositionsTab replaces the positions vec on each user.positions snapshot so Size / Entry / Liq track live.
  • 404d4f3 — Positions flicker fix. The previous commit left two writers racing for mark_price / pnl (payload-sourced on WS snapshot, ticker-recomputed on tick), producing a visible 0.00 → 1.23 → 0.00 → 1.24 flash. Extracted a single apply_live_mark_and_pnl helper owning those fields; parse_position_record now only sets static fields.
  • 14b50c7 — Portfolio tab. Same pattern applied: handle BalanceSnapshot, split parsing (parse_balance_record) from valuation (recompute_holding_values), call recompute from REST + WS + tick paths so non-stable holdings follow the market between user.balance deltas (which only fire on trades/deposits).
  • 05985a7 — Windows CI race fix. Collapse test_environment_urls and test_env_var_url_overrides into one sequential test. Both mutated process-global CDCX_* env vars and Rust's default parallel test harness raced them on Windows (slower env syscalls amplify the window). One test, one thread, race closed by construction. No new deps.

All three TUI tabs drop their on_activate resets — WS + tick recompute keep state current.

Test plan

  • cdcx tui → tab 3 Orders: open a limit order via CLI/another client, confirm it appears in the table without pressing r; cancel it and confirm the row disappears.
  • Tab 6 Positions: open or modify a position, confirm Size/Entry update live; observe mark price / P&L tick smoothly with no 0.00 flash on user.positions deltas.
  • Tab 2 Portfolio: place a trade that changes balances, confirm holdings update without refresh; watch non-stable holding USD values tick with the Market tab.
  • Switch between tabs rapidly — no "loading..." flash on re-entry.
  • cargo test --workspace passes locally; Windows CI race fix verified via 10-run loop.

llcro added 4 commits April 27, 2026 10:36
Beta testers reported that Orders showed stale data until manually
refreshed, and Positions only updated mark price / P&L live while Size
stayed frozen until refresh.

Root cause: user.order and user.positions WebSocket events were
reaching App::on_data but not the tabs — events were forwarded only to
the active tab, and OrdersTab/PositionsTab had no match arm for
DataEvent::OrdersUpdate / DataEvent::PositionsSnapshot. On top of
that, on_activate wiped loaded, forcing a REST round-trip every
tab-switch.

- Route user-channel deltas (OrdersUpdate, PositionsSnapshot,
  BalanceSnapshot) to their owning tabs regardless of which tab is
  active, so state stays fresh cross-tab.
- OrdersTab: upsert by order_id, remove on terminal statuses
  (FILLED / CANCELED / CANCELLED / REJECTED / EXPIRED). Share one
  parser between the REST snapshot and WS delta paths.
- PositionsTab: replace the positions vector on every user.positions
  snapshot so Size / Entry / Liq price follow the exchange live; the
  existing ticker-driven mark + P&L recompute is preserved.
- Drop on_activate resets for both tabs — WS now keeps state current,
  so wiping the cache on every tab-switch just produced a blank frame.
…riters

The previous commit left two writers competing for mark_price and pnl on
Position rows: parse_position_record seeded them from the WS/REST payload
(mark = last-known ask from state.tickers, pnl = session_pnl) while the
tick fallthrough recomputed both from state.tickers. Every snapshot
reset the fields to stale/zero values that the next tick repaired,
producing a visible 0.00 → 1.23 → 0.00 → 1.24 flicker whenever
user.positions deltas arrived.

Make the ticker-driven recompute the single source of truth:

- Extract apply_live_mark_and_pnl(positions, state) and call it from
  both rebuild_positions (WS snapshot / REST response) and the tick
  fallthrough so mark_price / pnl are only ever written in one place.
- parse_position_record now populates only static fields (instrument,
  side, qty, entry, liq) and leaves mark_price / pnl as 0.0 sentinels
  overwritten immediately by the recompute.
- Drop session_pnl parsing — it's exchange-session cumulative P&L and
  was semantically wrong for the per-position row anyway.
PortfolioTab was receiving DataEvent::BalanceSnapshot from the prior
commit's broadcast routing but had no match arm for it, so user.balance
WS deltas were silently dropped. Non-stable holdings also stored a
value computed once at REST-response time (total * ticker.ask) and
never recomputed between refreshes, so BTC/ETH rows stayed stale until
the next 30s REST tick even while the Market tab showed live prices.

Mirror the Positions fix:

- Handle DataEvent::BalanceSnapshot → rebuild_from_records, sharing one
  parser (parse_balance_record) between REST and WS paths.
- Split parsing from valuation: parse_balance_record populates only
  payload-sourced fields (name / amount / available); a single
  recompute_holding_values pass owns `value`, sourced from
  state.tickers. Called from REST, WS, and tick paths so holdings
  track the market between user.balance deltas (which fire on trades
  and deposits, not on price moves).
- Drop on_activate reset — WS + tick recompute keep state current,
  so wiping the cache on tab-switch just produced a blank frame.
test_environment_urls and test_env_var_url_overrides both mutate the
same process-global CDCX_* env vars. Rust's default test harness runs
tests on multiple threads in the same binary, so set_var calls in one
test race reads in the other. The override test's mid-flight
`set_var("CDCX_WS_USER_URL", ...)` sporadically leaked into the
defaults test's `ws_user_url()` read, producing the Windows CI failure:

  assertion `left == right` failed
    left:  "wss://custom-ws.example.com/user"
    right: "wss://stream.crypto.com/exchange/v1/user"

Windows is hit hardest because its env syscalls are slower, widening
the window. Merge the two tests into a single sequential test so all
set/assert/remove cycles run on one thread — no new deps, no behavior
change, race is closed by construction.
@llcro llcro merged commit 4f1df82 into main Apr 27, 2026
8 checks passed
@llcro llcro deleted the fix/orders-positions-realtime branch April 27, 2026 17:29
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