Skip to main content
Market Watch panels are user-configured surfaces that live above the fixed “Radar pulse” panes on /dashboard2/market-watch. Each panel is either a preset (one of eight pre-built fetchers) or a screen (filter expressed over a single read-only source).
These endpoints are session-authenticated only (Supabase cookie), not Bearer-API. They power the dashboard UI; external automation should use the public/agent APIs.
The system is gated three ways:
  1. Per-route rate limit — same withRequestLog wrapper as the rest of the dashboard. RPM + monthly hard cap per tier.
  2. Tier panel caps — number of panels you can keep is market_watch_panel_cap from tier_config. Screen panels have a tighter cap (market_watch_screen_panel_cap).
  3. Per-panel refresh floor — server clamps any schedule.cadenceMinutes to market_watch_min_refresh_seconds and a manual refresh has its own per-panel cooldown.
Default limits at launch:
TierTotal panelsScreen panelsMin refresh (s)Manual cooldown (s)
Free31300300
Hobby1256060
Pro50251515
Institutional20010055

Hydration

GET /api/dashboard2/market-watch-v2
Returns the legacy fixed-pane payload, every active panel for the calling user (with its cached payload), and the current tier limits + counts. Response is private, max-age=60, stale-while-revalidate=300. Panel fan-out is bounded at pMap(concurrency=4) so even an institutional tier with 200 panels only runs 4 factory calls at a time. Cache hits never reach Postgres.

Create a panel

POST /api/dashboard2/market-watch/panels
Body:
{
  "title": "SF Index 24h",
  "spec": {
    "version": 1,
    "kind": "preset",
    "title": "SF Index 24h",
    "preset": { "id": "sf_index_24h" },
    "display": { "visualization": "sparkline" },
    "schedule": { "mode": "manual" }
  }
}
Returns 201 with { "panel": { id, title, kind, status, sortIdx, refreshFloorSeconds } }. Failures use a stable reason enum:
ReasonWhen
spec_requiredbody has no spec
invalid_json_bodybody is not parseable JSON
kind_deferred_to_future_speckind is agentic_query, alert_linked, or external_delivery
kind_unknownkind is not preset or screen
preset_missing / preset_unknown_idpreset payload missing or id not in the registry
screen_sources_missing / screen_sources_empty / screen_source_unknownscreen panel source list invalid
filters_probability_out_of_rangefilters.probability.min/max outside [0, 1]
filters_window_invalidfilters.window not one of 1h / 6h / 24h / 7d / 30d
filters_tickers_too_manymore than 25 entries
display_missing / display_visualization_invaliddisplay block missing or visualization unknown
display_visualization_deferred_to_future_specreserved for agentic (digest)
schedule_mode_invalid / schedule_cadence_missing / schedule_cadence_below_tier_floorschedule shape or interval below tier floor
market_watch_panel_cap_exceededtier cap hit
market_watch_screen_panel_cap_exceededtier cap hit, screen-specific

Presets (v1)

Pass preset.id from this list:
Preset idWhat it returns
sf_index_24h4-line SF Index sparklines (disagreement / breadth / geoRisk / activity).
liquidity_by_themeStacked 24h volume share across themes.
regime_attentionMarkets bucketed by regime score in the latest 30m window.
cross_venue_contagion6h trigger→lagging signal rail across Kalshi/Polymarket.
movers_volume_zTop markets by 30d-baseline volume z-score.
data_healthPer-preset freshness + degraded flag.
calendar_catalystsUpcoming market expirations grouped by date.
watchlist_microstructurePer-user pinned tickers with price / volume / freshness.

Screen sources

For kind: "screen", pass sources as an array containing exactly one of:
latest_market_prices
market_regime_snapshots
sf_index_snapshots
liquidity_by_theme
contagion_bundles
watched_objects
alert_rules
public_query
public_context
v1 ships executor coverage for latest_market_prices; other sources validate but execute as empty.

Update / delete / reorder

PATCH  /api/dashboard2/market-watch/panels/{id}
DELETE /api/dashboard2/market-watch/panels/{id}
POST   /api/dashboard2/market-watch/panels/reorder
PATCH body accepts any subset of { title, spec, status, sortIdx }. Spec PATCH replaces the full spec — no JSON merge. Cache for the panel is invalidated after commit. Reorder body: { "order": [{ "id": "...", "sortIdx": 0 }, ...] }. All ids must be owned by the caller, else 404 panel_not_found_or_not_owned.

Manual refresh

POST /api/dashboard2/market-watch/panels/{id}/run
Synchronous: claims a per-user-per-panel cooldown via Upstash SET NX EX, runs the panel with allowStale=false, returns the fresh envelope.
{
  "panelId": "uuid",
  "status": "success-miss",
  "payload": { /* same shape as panels[].payload from hydration */ },
  "sourceClock": {},
  "generatedAt": "2026-05-22T18:00:01.000Z",
  "runId": "run_..."
}
Cooldown violations return 429 with Retry-After:
HTTP/1.1 429 Too Many Requests
Retry-After: 300
{ "reason": "panel_refresh_cooldown", "retryAfterSeconds": 300, "upgrade": { "url": "https://simplefunctions.dev/pricing" } }

Tier-gate headers

Every route inherits the dashboard rate-limit headers from withRequestLog:
  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset — minute window.
  • On block: 429 with Retry-After and X-SF-Block-Reason.

Errors and recovery

  • Redis outagerunPanelWithCache falls through to bypass (factory runs inline, no cache write). Cooldown bypass is graceful too: the panel’s refresh_floor_seconds floor still gates the cache window once Redis recovers.
  • Validator failure — never persists. The reason enum is stable and safe to surface.
  • Factory error — recorded in market_watch_panel_runs.error plus market_watch_panels.last_error. Replays still work via the next refresh.

Admin observability

GET /api/admin/market-watch/health
Admin-only (via ADMIN_EMAILS). Returns four aggregates:
{
  "panels_by_status_kind": [{ "status": "active", "kind": "preset", "panels": 7 }],
  "presets_active":        [{ "presetId": "sf_index_24h", "panels": 3 }],
  "runs_last_24h":         [{ "status": "success", "runs": 120, "errors": 0, "avgDurationMs": 18.5 }],
  "recent_errors":         [{ "id": "...", "title": "...", "kind": "...", "lastError": "...", "lastRunAt": "..." }],
  "generatedAt": "2026-05-22T18:00:00.000Z"
}
No fan-out beyond these four bounded queries.