Files
vyndr/DECISIONS.md
T
builtbykev 411cb6f196 feat: Feature 2.1 — Parlay Scan with correlation detection + monetization
POST /api/scan/parlay — authenticated parlay analysis:
- Supabase JWT auth middleware (auth.getUser verification)
- 5 correlation types detected between legs (same_game, same_team,
  same_player_conflicting, positive_correlation, blowout_cascade)
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month, atomic scan count increment
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 block with upgrade pitch
- Pitch personalization from scan history (top stats, grades, tier rec)
- DB writes: picks + scan_sessions per scan

30 new tests, 158 total (131 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:45:15 -04:00

7.4 KiB
Executable File

BetonBLK — Architecture Decisions

Format

Each decision follows this structure:

  • DECISION-[NNN]: [Title]
  • Date: [YYYY-MM-DD]
  • Context: [Why this decision was needed]
  • Decision: [What was decided]
  • Alternatives considered: [What else was on the table]
  • Consequences: [What this means going forward]

Decisions

DECISION-001: Odds API Raw Response Format (Feature 1.1)

  • Date: 2026-03-21
  • Context: Needed to verify actual Odds API response shape before writing normalizer. Made live test calls to /v4/sports/basketball_nba/events and /v4/sports/basketball_nba/events/{id}/odds.

Raw response structure (verified):

Event level:
  - id: "a1158df1a3a21def58491807df167c6a"
  - home_team: "Washington Wizards" (FULL NAME, not abbreviation)
  - away_team: "Oklahoma City Thunder"
  - commence_time: "2026-03-21T21:10:00Z" (ISO 8601 UTC)

Bookmaker level (nested under event.bookmakers[]):
  - key: "fanduel" | "draftkings" | "betmgm"
  - title: "FanDuel" | "DraftKings" (human-readable)

Market level (nested under bookmaker.markets[]):
  - key: "player_points" (matches our expected market keys)
  - last_update: "2026-03-21T12:17:04Z" (ISO 8601 UTC)

Outcome level (nested under market.outcomes[]):
  - name: "Over" | "Under"
  - description: "Shai Gilgeous-Alexander" (FULL PLAYER NAME)
  - price: -110 (American odds, integer)
  - point: 28.5 (the line)

Key findings:

  1. Team names are full names ("Washington Wizards"), NOT 3-letter abbreviations. We need a mapping table.
  2. Player names are in description field, full names.
  3. Over/Under for the same player+line appear as separate outcome objects. Must pair them.
  4. The API does NOT tell us which team a player belongs to. We only know home_team/away_team for the event. Player-to-team assignment requires roster data (Feature 1.2).
  5. markets param accepts comma-separated values — can fetch all 8 prop markets in one API call per event.

Quota headers (verified):

  • x-requests-used: cumulative credits used this month

  • x-requests-remaining: credits left

  • x-requests-last: credits consumed by this specific call (was 1)

  • Decision:

    1. Build a static NBA team name → 3-letter abbreviation mapping in utils.
    2. Normalizer must pair Over/Under outcomes by player name + point value.
    3. For Feature 1.1, set team to the full team name from the event. Player-to-team resolution deferred to Feature 1.2 integration.
    4. Fetch all markets in a single call per event to conserve credits.
    5. Use on-demand fetching only (not polling) — fetch from API only when a user request hits and cache is cold.
  • Alternatives considered:

    • Could skip team abbreviations entirely — rejected because downstream features (Prop Engine, UI) need short team identifiers.
    • Could try to resolve player→team via external lookup now — rejected because Feature 1.2 will provide this natively.
  • Consequences:

    • Need src/utils/teamMap.js with full name → abbreviation mapping.
    • Normalizer groups Over+Under outcomes by description + point.
    • Credit budget: ~1 credit per event per refresh. With 15-min cache + on-demand only, budget stays within 500/month for typical usage.

DECISION-002: Credit Conservation Strategy (Feature 1.1)

  • Date: 2026-03-21
  • Context: Starter plan = 500 credits/month. Player props require per-event API calls (sport-level endpoint only supports main markets). ~10 NBA games/day.
  • Decision: On-demand fetching only. Never poll. Cache aggressively at 15-min TTL. Batch all markets into one call per event. For a full NBA slate, one refresh = ~10 credits. At 15-min cache, even heavy usage stays under budget.
  • Alternatives considered: Background polling every 15 min — rejected, would burn ~480 credits per game day.
  • Consequences: First request after cache expires will be slower (live API call). Acceptable tradeoff for free tier.

DECISION-003: Python Microservice for nba_api (Feature 1.2)

  • Date: 2026-03-21
  • Context: nba_api is a Python library. Backend is Node.js/Express. Need a bridge.
  • Decision: FastAPI microservice. Node calls it via internal HTTP. Python stays idiomatic, caching strategy (24hr season averages, 1hr recent games) lives in the Python service with its own Redis connection.
  • Alternatives considered:
    • Python child process (Node spawns python3, parses stdout) — rejected: cold start on every call, awkward error handling.
    • Pre-fetch cron (Python writes to Redis on schedule, Node reads) — rejected: more moving parts, stale data risk.
  • Consequences: Two processes to run (Node + Python). Need a startup script or docker-compose. Internal port convention: Node on 3000, Python on 8000.

DECISION-004: Supabase Auth + RLS (Feature 1.4)

  • Date: 2026-03-21
  • Context: Need auth provider for user system. Supabase already in stack.
  • Decision: Supabase Auth. RLS enabled at project level. Our users table extends auth.users via FK on id. All tables use RLS policies scoped to auth.uid().
  • Alternatives considered:
    • Clerk — better DX but adds a vendor, costs more at scale.
    • Auth-agnostic (own users table, plug in later) — rejected: delays RLS setup, more migration work later.
  • Consequences: All table access goes through RLS. Service role key bypasses RLS for admin/backend operations. Anon key used for client-side auth flows.

DECISION-005: Spreads Market Added to Odds API Fetch (Feature 1.3)

  • Date: 2026-03-21
  • Context: Feature 1.3 kill condition blowout_risk needs game point spread. The Odds API supports a spreads market alongside player props.
  • Decision: Add spreads to the comma-separated markets list in the existing per-event API call. Zero additional API credits — the spreads data rides alongside the player prop data in the same request.
  • Alternatives considered: Skip blowout_risk for now — rejected because it's a high-value kill condition that prevents bad bets on blowout games.
  • Consequences: oddsService.js now returns a spreads array alongside props. The extractSpreads() function in oddsNormalizer.js parses game-level spread data separately from player-level props.

DECISION-006: Auth via Supabase auth.getUser() (Feature 2.1)

  • Date: 2026-03-21
  • Context: Need to verify Supabase JWTs in the scan endpoint. Options were manual JWT verification with secret or using Supabase client.
  • Decision: Use supabase.auth.getUser(token) from @supabase/supabase-js. Simpler, no JWT secret management, automatically validates token expiry and revocation.
  • Alternatives considered: Manual JWT decode with jsonwebtoken library — rejected: more code, need to manage JWT secret, doesn't check revocation.
  • Consequences: Auth middleware depends on Supabase API being reachable (same DNS blocker in WSL2). In production this is fine.

DECISION-007: Atomic Scan Count Increment (Feature 2.1)

  • Date: 2026-03-21
  • Context: Race condition risk — two concurrent scans from same free-tier user could both read scan_count=4 and both proceed past the limit.
  • Decision: Use atomic UPDATE users SET scan_count = scan_count + 1 WHERE id = :id AND scan_count = :current_count RETURNING scan_count. If no rows returned, another request incremented first.
  • Alternatives considered: Postgres advisory locks — rejected: overkill for this use case. Optimistic concurrency with the WHERE clause is simpler and sufficient.
  • Consequences: At worst, one of two concurrent requests will fail to increment and still proceed (the check happens before analysis). Acceptable for MVP — the 5-scan limit is soft, not a billing boundary.