- TypeScript 93.5%
- HTML 5.1%
- CSS 0.8%
- Dockerfile 0.6%
|
All checks were successful
Build & Push Docker Images / build-and-push (push) Successful in 2m21s
|
||
|---|---|---|
| .forgejo/workflows | ||
| apps | ||
| docs/superpowers | ||
| packages/shared | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| CONTEXT.md | ||
| docker-compose.auth.yml | ||
| docker-compose.deploy.yml | ||
| docker-compose.yml | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| polymarket-signal-dashboard.html | ||
| polymarket_signal_dashboard_prd.md | ||
| README.md | ||
| tsconfig.base.json | ||
| vitest.workspace.ts | ||
Polymarket Signal Dashboard
Read-only monitoring dashboard for Polymarket-derived trading signals. Modular monolith: Fastify + TypeScript backend, Vite + React SPA, SQLite (Drizzle).
⚠️ Single-user, no auth — keep it network-isolated. There is deliberately no authentication: anyone who can reach the port can rewrite scoring weights, ack alerts, and read signal history. Deploy only on a private network (LAN / VPN / Tailscale); do not port-forward or expose it to the internet. Secrets (webhook URL, LLM keys) live in server-side env vars and never reach the UI.
Milestone status: M5c (settings persistence) done — M5 COMPLETE. M1–M5 done. M1 delivered the static shell with mocked adapters; M2 connected the real Gamma discovery + CLOB polling pipeline and streams rows over SSE; M3 adds a real scoring engine that computes a 0–10 signal score for every market from snapshot history; M4 enriches crypto/ETF/commodity markets with live Binance and Yahoo Finance data so the underlying asset move can confirm or contradict the prediction-market repricing; M5a classifies each market into a catalyst category (once, on discovery) and surfaces it in the scanner; M5b adds an alerting engine that fires on significant repricing moves and delivers them in-app, via browser notifications, and via an optional webhook; M5c makes all scoring, alert, watch, and theme settings editable in the UI and persists them to SQLite, applied live without restart. See
docs/superpowers/specs/anddocs/superpowers/plans/for the roadmap.
Prerequisites
- Node 20+ (developed/tested on Node 26)
- pnpm (
corepack enable && corepack prepare pnpm@latest --activate, or install pnpm directly)
Setup
pnpm install
cp apps/server/.env.example apps/server/.env
pnpm --filter @pmsd/server db:generate # only if apps/server/drizzle/ migrations are missing
Native module note: the server uses
better-sqlite3(a native addon). On very new Node versions a prebuilt binary may be unavailable and pnpm will compile it from source (requires Xcode CLT / build tools). If you hit a build error, ensure build tools are installed and re-runpnpm install.
Run (two terminals)
pnpm dev:server # http://localhost:8787
pnpm dev:web # http://localhost:5173 (proxies /api to the server)
Open http://localhost:5173 — the Scanner (landing) streams live Polymarket data over SSE.
Run with Docker
Requires Docker with Compose v2.
docker compose up --build
Then open http://localhost:8080. Data persists in the pmsd-data volume.
Optional configuration: cp .env.example .env and edit before starting.
Data sources (M2)
Live mode (default)
By default (USE_MOCK=false) the server fetches real data from Polymarket:
- Gamma API (
GAMMA_BASE_URL, defaulthttps://gamma-api.polymarket.com) — discovers active markets ordered by 24-hour volume. The topical filter keeps only markets with a tradable underlying (crypto / commodity / equity ETF keyword match) or macro/catalyst relevance (Fed, FOMC, CPI, interest-rate, SEC, ETF, GDP, etc.). Allow/deny slug overrides let you pin or exclude specific markets regardless of the filter. - CLOB API (
CLOB_BASE_URL, defaulthttps://clob.polymarket.com) — polls the top-of-book for each discovered market's YES token, producingmidpoint,bestBid,bestAsk, andspreadCents. - openInterest is sourced from Gamma's
liquidityfield (USD notional); no separate OI endpoint is called.
Discovery runs once at startup and then on the DISCOVERY_INTERVAL_MS cadence (default 2 min).
Price ticks run every POLL_INTERVAL_MS (default 8 s) over the current market set and are
streamed to connected browsers over SSE.
Offline / mock mode
Set USE_MOCK=true to run fully offline on the built-in deterministic mock. The mock produces
the same six seed markets (BTC, ETH, SOL, IBIT, gold, SEC-event) with pseudo-random noise that
is consistent across restarts — useful for local UI work and CI with no network.
USE_MOCK=true pnpm dev:server
Watch / config env vars
| Variable | Default | Description |
|---|---|---|
USE_MOCK |
false |
Set true for offline deterministic mock |
WATCH_TOP_N |
30 |
Max markets to track from Gamma |
WATCH_ALLOW |
(empty) | Comma-separated slugs to restrict discovery to (exclusive allowlist; overrides topical filter when non-empty) |
WATCH_DENY |
(empty) | Comma-separated slugs to force-exclude |
DISCOVERY_INTERVAL_MS |
120000 |
How often to re-run Gamma discovery |
POLL_INTERVAL_MS |
8000 |
CLOB price-tick cadence |
RETENTION_DAYS |
7 |
How many days of snapshot history to retain in SQLite |
GAMMA_BASE_URL |
https://gamma-api.polymarket.com |
Gamma API base |
CLOB_BASE_URL |
https://clob.polymarket.com |
CLOB API base |
REQUEST_TIMEOUT_MS |
8000 |
Per-request HTTP timeout |
Scoring (M3)
Every market is scored on a 0–10 scale each poll tick once enough snapshot history has accumulated in SQLite. The score is a weighted blend of three components:
| Component | What it measures |
|---|---|
| Momentum | 15-minute and 60-minute midpoint repricing magnitude — how much the market is moving |
| Market Quality | Spread tightness — wide-spread markets are downgraded to prevent thin-market blips from topping the ranking |
| Participation | Open-interest liquidity — deeper markets carry more weight |
Two scoring profiles are supported and are auto-selected by instrument class:
asset_confirmed— used for crypto/equity/commodity markets that have a paired spot price feed (e.g.BTCUSDT). Includes theassetConfirmationcomponent (M4, wired).event_macro— used for event/macro markets (Fed, CPI, SEC, etc.). The catalyst component (M5a) is wired and carries weight 2 in this profile.
The profile can also be set explicitly via a market's profileOverride field.
Score renormalisation: the engine renormalises over the components that are available,
so the 0–10 scale is maintained regardless of which components return null. In the Market
Detail view the breakdown shows N/A for catalyst on asset_confirmed markets (no catalyst weight in that profile).
Wide-spread downgrade: if spreadCents exceeds hardSpreadCents in ScoringConfig
a spreadPenaltyFactor is applied to the market quality sub-score, preventing illiquid markets
from scoring highly on momentum alone.
Configuration: weights and thresholds live in ScoringConfig (see packages/shared) and are editable in the Settings view (M5c), persisted to SQLite.
The per-component breakdown is visible in the Market Detail view (click any row in the
Scanner). The delta15m / delta60m columns show null (rendered as "—" in the UI) until
enough price history has accumulated.
Confirmation (M4)
Every crypto, ETF, and commodity market is now cross-referenced against its underlying asset during each poll tick to assess whether the real-world move agrees with the prediction-market repricing.
Data sources
| Instrument class | Provider | Signals fetched |
|---|---|---|
crypto |
Binance spot + futures | Spot return (priceChangePercent) over the confirmation window; perp open-interest change; last funding rate |
equity_etf / commodity |
Yahoo Finance | Spot return (regularMarketChangePercent); underlying volume |
Direction inference: the market title is parsed to infer whether a YES outcome represents a bullish or bearish underlying move. Keywords like above, reach, up, break map to long; below, dip, drop, crash map to short (default: long when ambiguous).
assetConfirmation component (0–1): measures agreement between the prediction-market
repricing (15-minute or 60-minute prob delta) and the directional underlying move. A score
above 0.5 means the spot is moving with the bet; below 0.5 means it is moving against it.
The perp OI change adds a small ±boost for crypto markets when OI builds in the same direction.
The component returns null and does not affect the score when there is no significant
probability movement (|probDelta| < confirmationMinProbPts, default 1 pt).
confirmationState is displayed as a badge in the Scanner and the Market Detail view:
| State | Meaning |
|---|---|
confirmed |
assetConfirmation ≥ 0.6 — underlying is moving with the repricing |
mixed |
assetConfirmation ≤ 0.4 — underlying is moving against the repricing |
unconfirmed |
Binance/Yahoo data fetched but probability delta too small to assess, or cold start |
na |
Event/macro market (event_macro profile) — no spot asset to confirm against |
Asset-confirmed renormalisation: when assetConfirmation is non-null the scoring
denominator grows to include its weight, increasing the effective range and rewarding markets
where both the probability move and the spot move align.
Resilience: each fetch is wrapped in a try/catch. A Binance or Yahoo error (timeout,
rate-limit, network block) silently degrades to an empty confirmation, leaving
confirmationState=unconfirmed and assetConfirmation=null. The server never crashes and
the existing momentum/quality/participation score is still returned.
Disabling confirmation
CONFIRMATION_ENABLED=false pnpm dev:server # skip all Binance/Yahoo calls
USE_MOCK=true pnpm dev:server # fully offline; no confirmation
Confirmation env vars
| Variable | Default | Description |
|---|---|---|
CONFIRMATION_ENABLED |
true |
Set false to skip all Binance/Yahoo confirmation calls |
CONFIRMATION_WINDOW |
15m |
Lookback window used when fetching spot returns |
BINANCE_SPOT_URL |
https://api.binance.com |
Binance spot API base URL |
BINANCE_FUTURES_URL |
https://fapi.binance.com |
Binance futures (perp) API base URL |
YAHOO_URL |
https://query1.finance.yahoo.com |
Yahoo Finance API base URL |
Catalyst tagging (M5a)
Each market is classified into a catalyst category exactly once during discovery. The tag is cached on the market record and never re-tagged on subsequent discovery runs.
Categories: regulatory, etf_flow, macro_data, monetary_policy, hack_exploit, token_unlock, listing_partnership, headline_only, none.
Tagging back-end — two modes, selected by environment variable:
| Mode | Env | Notes |
|---|---|---|
| Rule-based (default) | CATALYST_PROVIDER unset |
Keyword regex classifier; no API key required; runs offline |
| Anthropic LLM | CATALYST_PROVIDER=anthropic + CATALYST_API_KEY |
Uses Anthropic SDK structured output for higher accuracy |
| OpenAI-compatible LLM | CATALYST_PROVIDER=openai + CATALYST_API_KEY + optional CATALYST_BASE_URL + CATALYST_MODEL |
Works with OpenAI, Ollama (local), or any OpenAI-compatible endpoint |
Scoring integration: the category maps to a 0–1 catalyst score component via ScoringConfig.catalystStrengths (e.g. etf_flow: 0.9, monetary_policy: 0.8, none: 0). For event_macro markets the catalyst component carries weight 2 in the profile, completing the scoring engine. The tag and its strength contribute to the final 0–10 score through the same renormalising blend used by all other components.
Scanner: the catalyst tag is displayed in the Catalyst column (between Class and Midpoint), showing — when the tag is absent.
Remaining milestones: none — M5 is complete.
Alerting (M5b)
Two distinct alert pipelines run on every poll tick. They serve different purposes and must not be conflated:
Trade signals (alerts) |
Market alerts (market_alerts) |
|
|---|---|---|
| Granularity | Per symbol (BTC, ETH, …) | Per market (one Polymarket question) |
| Nature | Actionable (OPEN_LONG / OPEN_SHORT / CLOSE / REVERSE) |
Informational (a market is repricing fast) |
| Channels | In-app rail, browser notification, webhook | In-app rail, browser notification — never the webhook |
| API | GET /api/alerts, POST /api/alerts/:id/{ack,dismiss} |
GET /api/market-alerts, POST /api/market-alerts/:id/{ack,dismiss} |
Trade signals (per-symbol trend changes)
Every tick the markets sharing an underlying symbol are aggregated into a symbol trend (bullish / bearish / neutral, with conviction 0–100). A trade signal fires when the trend changes state and the change survives gating:
- the new direction must persist for
alert.confirmTicksconsecutive ticks (default 2), and - emergence from neutral requires
alert.minConviction(default 34).
Severity: critical for a bullish↔bearish reversal, warning for emergence from neutral, info for a fade to neutral. Storage is latest-per-symbol (a newer signal replaces the previous row for that symbol). The webhook (ALERT_WEBHOOK_URL) carries only trade signals.
Market alerts (per-market repricing)
A market alert fires when, over the last 15 minutes, a single market's midpoint move meets both conditions:
- Move ≥
marketAlert.minMove15m(default 8 probability points) — a meaningful repricing has occurred. - Spread ≤
marketAlert.maxSpreadCents(default 4 cents) — suppressed when the spread indicates a thin, noise-dominated book.
Severity is upgraded to warning when the underlying confirms the move (confirmationState = confirmed); event-only markets stay info and are labelled event-only in the reason. A per-market cooldown (marketAlert.cooldownMinutes, default 15) prevents repeat alerts while the same move stays inside the window; it is DB-backed and survives restarts. All three thresholds are editable in Settings and reloaded live on every tick.
Signal outcomes
Every directional trade signal is stamped with the underlying spot price at emission (Binance for crypto, Yahoo for equities/commodities). A background job then fills the realized return at +15m, +1h, and +4h as each horizon elapses. The Alerts view shows a per-(symbol, action) summary — count, hit rate per horizon (a hit is a return whose sign matches the signal direction), and average return — which is the product's measure of signal quality. GET /api/outcomes returns raw outcomes plus the summary. Disabled in mock mode or when confirmation is disabled (no price source).
Alerting env vars
| Variable | Default | Description |
|---|---|---|
ALERT_WEBHOOK_URL |
(empty) | Optional URL to POST trade-signal JSON on each new signal (never market alerts) |
Settings (M5c)
All of the following settings are editable in the Settings view of the UI and are persisted to SQLite. Changes take effect live without restarting the server:
- Scoring —
profiles(component weights per profile:momentum,marketQuality,participation,assetConfirmation,catalyst),hardSpreadCents(spread threshold above which the quality sub-score is penalised),spreadPenaltyFactor(penalty multiplier applied when spread exceedshardSpreadCents),confirmationMinProbPts, andcatalystStrengths(per-category 0–1 strength values, e.g.etf_flow: 0.9,monetary_policy: 0.8,none: 0). Scoring re-reads settings from SQLite on every poll tick. - Trade-signal gating —
alert.confirmTicks(consecutive ticks a trend change must persist) andalert.minConviction(conviction floor for emergence signals). Re-read from SQLite on every tick. - Market alerts —
marketAlert.minMove15m,marketAlert.maxSpreadCents, andmarketAlert.cooldownMinutes(see Alerting above). - Watch —
topN(max markets to track from Gamma discovery),allow(comma-separated slug allowlist),deny(comma-separated slug denylist), andexpiryGuardMinutes(markets ending within this window are never onboarded and stop voting in symbol trends/trade signals; default 30, 0 disables). Watch settings are applied at the next discovery run. - Theme —
lightordark; applied immediately in the UI.
Seeding and priority
Environment variables (WATCH_TOP_N, WATCH_ALLOW, WATCH_DENY, POLL_INTERVAL_MS, RETENTION_DAYS) seed the initial settings row on first boot only. Once a settings row exists in SQLite the persisted values take precedence; env vars are ignored for subsequent restarts. ALERT_WEBHOOK_URL is env-only (treated as a secret and never stored in the database).
Per-market overrides (M5c)
From the Market Detail view, a market's scoring profile (asset_confirmed / event_macro), catalyst tag, and polarity (the direction its YES outcome implies for the underlying — it signs the market's vote in the symbol trend) can be overridden independently of the global settings. The override is persisted on the market record and picked up on the next scoring tick.
API: PATCH /api/markets/:id — accepts profileOverride, catalystTag, and polarity ("up" / "down" / null to re-infer) fields; returns the updated market object.
Status
The product is feature-complete across all planned milestones:
| Milestone | Deliverable |
|---|---|
| M1 | Static shell — hardcoded mock data, Scanner table, Market Detail, React SPA scaffold |
| M2 | Live Polymarket — Gamma discovery + CLOB polling, SSE streaming, offline mock mode |
| M3 | Scoring engine — 0–10 weighted score (momentum, market quality, participation), per-component breakdown |
| M4 | Confirmation — Binance (crypto) + Yahoo Finance (ETF/commodity) cross-reference, assetConfirmation component, confirmationState badge |
| M5a | Catalyst tagging — rule-based + optional LLM classifier, catalyst score component, Scanner column |
| M5b | Alerting — repricing-triggered alerts, severity by confirmation state, in-app SSE rail + browser notifications + webhook |
| M5c | Settings persistence — editable scoring/alert/watch/theme in Settings UI, SQLite-backed, live-reloaded; per-market profile overrides in Market Detail |
Test
pnpm test # unit/integration (shared + server + web)
pnpm typecheck # strict TypeScript across all packages
End-to-end (Playwright)
pnpm --filter @pmsd/web exec playwright install chromium # one-time
pnpm dev:server # in one terminal
pnpm --filter @pmsd/web e2e # in another