No description
  • TypeScript 93.5%
  • HTML 5.1%
  • CSS 0.8%
  • Dockerfile 0.6%
Find a file
Andrea Pregnolato bc853f1782
All checks were successful
Build & Push Docker Images / build-and-push (push) Successful in 2m21s
docs: correct migration number in trading-automation spec (0009)
2026-06-13 09:17:27 +02:00
.forgejo/workflows ci: use REGISTRY_TOKEN PAT for registry login (write:package) 2026-06-10 01:37:55 +02:00
apps fix(server): gate flattener on price source; validate automation override rows 2026-06-13 09:17:18 +02:00
docs/superpowers docs: correct migration number in trading-automation spec (0009) 2026-06-13 09:17:27 +02:00
packages/shared feat(shared): automation mode enum and override schema 2026-06-13 08:48:54 +02:00
.dockerignore chore: add .dockerignore and .env.example for containerized run 2026-06-06 23:24:50 +02:00
.env.example chore(deploy): allow binding published port via DEPLOY_BIND 2026-06-11 18:23:26 +02:00
.gitignore feat: API hardening — CSRF guard, rate limit, security headers, opt-in basic auth (#10) 2026-06-13 01:36:11 +02:00
CLAUDE.md Add approved design spec for Polymarket Signal Dashboard 2026-06-02 11:03:08 +02:00
CONTEXT.md docs: add domain CONTEXT.md from design review 2026-06-10 15:41:02 +02:00
docker-compose.auth.yml feat: API hardening — CSRF guard, rate limit, security headers, opt-in basic auth (#10) 2026-06-13 01:36:11 +02:00
docker-compose.deploy.yml chore(deploy): allow binding published port via DEPLOY_BIND 2026-06-11 18:23:26 +02:00
docker-compose.yml feat(docker): docker-compose for one-command containerized run 2026-06-06 23:47:00 +02:00
package.json feat(web): wire router across all five views; make workspace typecheck clean 2026-06-02 13:11:28 +02:00
pnpm-lock.yaml feat: API hardening — CSRF guard, rate limit, security headers, opt-in basic auth (#10) 2026-06-13 01:36:11 +02:00
pnpm-workspace.yaml build: declare onlyBuiltDependencies in pnpm-workspace.yaml 2026-06-10 01:24:38 +02:00
polymarket-signal-dashboard.html Add approved design spec for Polymarket Signal Dashboard 2026-06-02 11:03:08 +02:00
polymarket_signal_dashboard_prd.md Add approved design spec for Polymarket Signal Dashboard 2026-06-02 11:03:08 +02:00
README.md feat: market alerts, outcome tracking, expiry guard, polarity override 2026-06-10 15:41:18 +02:00
tsconfig.base.json chore: scaffold pnpm monorepo workspace 2026-06-02 11:18:52 +02:00
vitest.workspace.ts chore: scaffold pnpm monorepo workspace 2026-06-02 11:18:52 +02:00

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. M1M5 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 010 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/ and docs/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-run pnpm 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, default https://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, default https://clob.polymarket.com) — polls the top-of-book for each discovered market's YES token, producing midpoint, bestBid, bestAsk, and spreadCents.
  • openInterest is sourced from Gamma's liquidity field (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 010 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 the assetConfirmation component (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 010 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 (01): 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 01 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 010 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 0100). A trade signal fires when the trend changes state and the change survives gating:

  • the new direction must persist for alert.confirmTicks consecutive 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:

  • Scoringprofiles (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 exceeds hardSpreadCents), confirmationMinProbPts, and catalystStrengths (per-category 01 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 gatingalert.confirmTicks (consecutive ticks a trend change must persist) and alert.minConviction (conviction floor for emergence signals). Re-read from SQLite on every tick.
  • Market alertsmarketAlert.minMove15m, marketAlert.maxSpreadCents, and marketAlert.cooldownMinutes (see Alerting above).
  • WatchtopN (max markets to track from Gamma discovery), allow (comma-separated slug allowlist), deny (comma-separated slug denylist), and expiryGuardMinutes (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.
  • Themelight or dark; 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 — 010 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