Skip to content

docs: add comprehensive market data design reference - #2

Merged
raunaksachdev merged 1 commit into
mainfrom
claude/arithmetic-question-sdvkdo
Aug 25, 2026
Merged

docs: add comprehensive market data design reference#2
raunaksachdev merged 1 commit into
mainfrom
claude/arithmetic-question-sdvkdo

Conversation

@raunaksachdev

Copy link
Copy Markdown
Owner

Summary

Add a detailed, implementation-ready design document for the market data subsystem that serves as both an onboarding guide and a specification reference. This document captures the current state of the fully-built and tested market data layer, superseding an earlier archived design that had drifted from the actual implementation.

Changes

  • New file: planning/MARKET_DATA_DESIGN.md (1,557 lines)
    • Comprehensive reference covering all market data components: PriceUpdate data model, thread-safe PriceCache, abstract MarketDataSource interface, GBM simulator with correlated price movements, Massive API client, factory pattern for source selection, SSE streaming endpoint, and FastAPI lifecycle integration
    • Includes complete, production-ready code blocks extracted directly from the implementation under backend/app/market/
    • Documents design decisions, mathematical foundations (GBM formula, Cholesky decomposition for correlation), error handling philosophy, and testing strategy
    • Provides clear examples of usage patterns and integration points for downstream consumers (portfolio valuation, trade execution, watchlist management)
    • Explains the environment-variable-driven toggle between simulator (default) and Massive API (when MASSIVE_API_KEY is set)

Notable Details

  • Code blocks are taken directly from the real implementation, making this document both a spec and an accurate onboarding reference
  • Includes a component diagram showing data flow from sources through the shared cache to consumers
  • Documents the GBM simulator's correlated price movements via Cholesky decomposition and sector-based correlation groups
  • Explains why the SSE endpoint polls the cache on a fixed interval rather than being event-driven (predictable spacing for frontend sparkline charts)
  • Covers edge cases like removing a ticker from the watchlist while still holding an open position
  • References the existing 73-test suite with 91% coverage and identifies the SSE stream testing gap (33% coverage)
  • Provides a table of all test modules and what they cover

This document is the implementation-ready reference that a fresh implementation could be rebuilt from, and doubles as the primary onboarding material for the market data subsystem.

https://claude.ai/code/session_01RyKr4TS8gMZPNU8roFiYUT

Writes planning/MARKET_DATA_DESIGN.md as an implementation-ready
reference covering the unified MarketDataSource interface, PriceCache,
GBM simulator, Massive API client, SSE streaming, and FastAPI lifecycle
wiring. Code snippets are verified against the current backend/app/market/
implementation, correcting drift present in the older
planning/archive/MARKET_DATA_DESIGN.md (lazy-import framing for the
massive client, GBMSimulator.get_tickers() visibility, stream.py's
generator return type).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RyKr4TS8gMZPNU8roFiYUT
Comment on lines +1087 to +1093
router = APIRouter(prefix="/api/stream", tags=["streaming"])


def create_stream_router(price_cache: PriceCache) -> APIRouter:
"""Create the SSE streaming router with a reference to the price cache.

This factory pattern lets us inject the PriceCache without globals.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

router is declared at module scope here, but the docstring on line 1093 claims the factory pattern "lets us inject the PriceCache without globals" — that's the opposite of what the code does. create_stream_router() decorates and returns this same shared global router rather than constructing a new APIRouter() internally.

Since the only call site is inside lifespan (line 1227), calling it more than once (e.g. multiple app/test instances in the same process) would register a second /prices route on the same global router, and Starlette would only ever match the first-registered one — silently serving from a stale/wrong PriceCache.

Fix: construct APIRouter(...) inside create_stream_router() instead of at module level, so the docstring's claim is actually true.

Comment on lines +1257 to +1261
@router.post("/portfolio/trade")
async def execute_trade(trade: TradeRequest, price_cache: PriceCache = Depends(get_price_cache)):
current_price = price_cache.get_price(trade.ticker)
if current_price is None:
raise HTTPException(404, f"No price available for {trade.ticker}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This raises HTTPException(404, ...) when there's no cached price for the trade ticker, but §13.2 describes the exact same condition (cache miss during a trade) and explicitly argues 400 is "the correct response for that window," using the identical price_cache.get_price(ticker) is None check. An implementer following this section would ship a different status code than the one §13.2 specifies as correct — these two need to agree on one status code.

Comment on lines +913 to +920
async def add_ticker(self, ticker: str) -> None:
ticker = ticker.upper().strip()
if ticker not in self._tickers:
self._tickers.append(ticker)
logger.info("Massive: added ticker %s (will appear on next poll)", ticker)

async def remove_ticker(self, ticker: str) -> None:
ticker = ticker.upper().strip()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MassiveDataSource.add_ticker/remove_ticker normalize the ticker via .upper().strip(), but the simulator's equivalent methods (lines 734, 743) pass the ticker straight through without normalization. This contradicts the doc's own claim that downstream code "never knows or cares" which source is active — a lowercase ticker like "tsla" would work correctly against Massive but would miss SEED_PRICES["TSLA"] under the simulator, creating a distinct phantom ticker with a random seed price. Since no upstream layer (route handlers) normalizes either, this asymmetry should be resolved — ideally by normalizing once at the interface boundary rather than duplicating it into the simulator.

Comment on lines +1440 to +1446

@pytest.mark.asyncio
async def test_sse_stream_emits_prices():
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
async with client.stream("GET", "/api/stream/prices") as response:
assert response.status_code == 200

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This recommended test uses httpx.ASGITransport without running the ASGI lifespan protocol, but the SSE route is only registered inside lifespan (line 1227), not at module scope. ASGITransport doesn't run lifespan by default, so /api/stream/prices would never be registered and this request would return 404, failing the status_code == 200 assertion on line 1446 — the opposite of what this section is recommending it for (closing the stream.py coverage gap). It needs asgi_lifespan.LifespanManager (or an equivalent lifespan-aware test client) to work as intended.

|--------|-------|-----------------|
| `test_models.py` | 11 | `PriceUpdate` properties (`change`, `direction`, `change_percent`), `to_dict()` |
| `test_cache.py` | 13 | `PriceCache` update/get/get_all/remove, version increments, first-update-is-flat |
| `test_simulator.py` | 17 | `GBMSimulator` math: positive prices, drift over many steps, add/remove ticker rebuilds Cholesky, unknown-ticker random seed |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This row says test_simulator.py has 17 tests, but the total stated two lines above is "73 tests" — the table as written sums to 71 (11+13+17+10+7+13), not 73. The actual count in backend/tests/market/test_simulator.py is 19, which makes the table sum to 73 and matches the stated total — so this row's count is the one that's wrong.

Suggested change
| `test_simulator.py` | 17 | `GBMSimulator` math: positive prices, drift over many steps, add/remove ticker rebuilds Cholesky, unknown-ticker random seed |
| `test_simulator.py` | 19 | `GBMSimulator` math: positive prices, drift over many steps, add/remove ticker rebuilds Cholesky, unknown-ticker random seed |

Comment on lines +840 to +841
Rate limits: **free tier → 5 req/min → poll every 15s** (default);
**paid tiers → poll every 2–5s**. See `planning/MASSIVE_API.md` for the full

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This states paid tiers poll "every 2–5s" (repeated at line 872), but planning/PLAN.md explicitly states "Paid tiers: poll every 2-15 seconds depending on tier." The design doc narrows this to a 2-5s upper bound in both places, understating PLAN.md's documented max interval.

@raunaksachdev
raunaksachdev merged commit a2450a0 into main Aug 25, 2026
13 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.

2 participants