Raxx · internal docs

internal · gated

ADR-0079: WCB Snapshot-Only Storage with Compute-on-Render Trajectory

Date: 2026-05-12 UTC Status: Accepted Epic: #1657 Design doc: docs/architecture/what-couldve-been-design.md Deciders: architect agent, data-scientist brief (PR #1658)


Context

The "What Could've Been" (WCB) feature needs to display two things after a user closes a position:

  1. A single finalized P&L figure at the tracking horizon (EOD or EOM).
  2. A sparkline showing the trajectory of would-have P&L from close-date to horizon.

The question is how much of this data to store persistently and how much to compute on demand.

Three options were evaluated:

Option A — Store full daily trajectory rows: Create one row per position per trading day from close-date to horizon. For a Pro+ user with 20 open EOM-tracked positions in a 22-trading-day month, this produces 440 rows per month per user. At scale (1,000 Pro+ users), this is 440,000 rows per month — before accounting for historical positions already closed.

Rejected because: O(positions × trading_days) cardinality is unnecessary for a feature that renders a sparkline from ≤31 data points. The bar data is already cached in AlpacaMarketDataService's LRU and is cheap to re-fetch. Storing it again wastes storage and complicates the schema.

Option B — Pure compute-on-render (no snapshot table): Compute everything at render time from the position's close record and Alpaca bar data. No new table. No scheduled jobs.

Rejected because: the horizon finalization is non-idempotent across job runs. Specifically: - Dividend ex_date data may not be available on the exact close day (data lag; see design doc §5.3). A pure-compute path would return different P&L values depending on when the user renders the page — before vs. after Alpaca publishes the dividend record. - For EOD, the user expects a stable, finalized P&L once the trading day closes. A pure-compute path on page render can produce drift if Alpaca bar data is adjusted retroactively (e.g., split adjustment applied after the fact). - Auditability: there is no record of what value was shown to the user at a given time. A stored snapshot provides a stable, auditable record.

Option C — Snapshot-only storage with compute-on-render trajectory (recommended): Store one row per position per tracking horizon in closed_position_wcb_snapshots. The row captures the snapshot inputs at position-close time and is updated exactly once by the scheduled job when the horizon is reached. The daily trajectory is computed on render from Alpaca bar data (≤31 bars) and cached in Redis with a 1-hour TTL.


Decision

Option C is adopted.

Store one snapshot row per position per tracking horizon. Scheduled jobs are the only writers of horizon_price, would_have_pnl, actual_pnl, delta_pnl, and horizon_reached. Once horizon_reached = TRUE, the snapshot is immutable. The render path computes the trajectory dynamically from Alpaca bar data and caches the result in Redis.


Consequences

Positive:

Negative / constraints imposed:

Invariants enforced by this decision:


Alternatives Considered

See Option A and Option B descriptions in the Context section above.

An additional alternative was considered during brief review:

Option D — Materialized view: Compute the trajectory as a Postgres materialized view refreshed by the scheduled job.

Rejected because: Postgres materialized views do not support per-row refresh (refreshing the view for one position refreshes the entire view). For a table with thousands of positions across thousands of users, a full-view refresh on every EOD job run is impractical. Row-level granularity requires either explicit row writes (Option A) or on-render compute (Option C).