A paper-first multi-coin, multi-user crypto trading cockpit. It tracks a registry of coins (BTC, ETH, SOL, …), ingests real market data per coin, computes an evidence-based signal scorecard for each one daily (once, shared by everyone), and gives each user their own watchlist, virtual $10,000 paper account, auto-trader, risk settings and alerts — with realistic fees and slippage, per-coin and total exposure caps, and backtesting against buy-and-hold. Accounts come with email+password or Google login and two paid subscription tiers via Stripe. Live exchange trading is intentionally off and gated behind multiple guards.
Built as a sister app to the Schwab "Signal Deck" stocks tool, sharing its architecture (Node 22 + Express + Postgres + node-cron, server-rendered UI, Railway deploy).
---
PaperBrokerAdapter. No real funds, no exchange keys needed.LIVE_TRADING_ENABLED=false is the default env guard. The broker factory refuses to construct a live adapter unless it's true.broker_connections.confirmed_at set (an explicit in-app confirmation, not yet exposed in the UI).CoinbaseAdapter.placeOrder throws by design. Read-only public price data is the only live thing it does.client_order_id; duplicates are no-ops.---
`
web/server.js Express app + auth middleware + all routes + cron start
web/views.js Server-rendered HTML (every page incl. login/billing/admin)
lib/auth.js Users, scrypt passwords, hashed sessions, resets, admin bootstrap
lib/google.js "Continue with Google" OAuth code flow
lib/plans.js Free/Starter/Pro tiers + gating helpers
lib/billing.js Stripe checkout / webhook / customer portal
lib/telegram.js Telegram bot: linking, webhook secret, alert messages
lib/email.js Resend email (verify/reset/alerts), per-recipient
lib/alerts.js Per-user BUY-flip + trade alerts (email + Telegram)
lib/coins.js Global coin registry + per-user watchlists
workers/index.js Cron schedule + JOBS registry + runLogged()
workers/run.js CLI: node workers/run.js <job>
workers/ingest.js Coinbase price tick + daily candles for every coin
workers/signals.js Daily signal computation + snapshot per coin (shared)
workers/fear_greed.js / onchain.js Context + on-chain data
workers/autotrader.js Per-user paper auto-trader (plan-gated)
workers/paper_snapshot.js / prune.js Per-user equity snapshots + retention
lib/db.js pg pool, q(), settings KV, fetchLog(), audit()
lib/settings.js SYSTEM (shared) + USER (per-account) settings & defaults
lib/coinbase.js Public Coinbase ticker + candles (no key)
lib/feargreed.js Alternative.me Fear & Greed
lib/onchain.js BGeometrics MVRV/SOPR/NUPL/Puell
lib/indicators.js SMA, momentum, ATR, realized vol, drawdown, RS ranking
lib/scoring.js The signal scorecard + decision engine + alt gates (pure)
lib/exits.js Adaptive exit engine: stops/trailing/reduces (pure)
lib/risk.js Risk gates + position sizing (per user)
lib/paper.js Per-user paper portfolio, orders, fills, P&L
lib/portfolio.js Fill-replay money math (pure)
lib/backtest.js Walk-forward backtester, one coin (pure)
lib/portfolio_backtest.js Whole-system backtester: shared cash + RS gate (pure)
lib/broker/ BrokerAdapter interface + paper/coinbase/alpaca
db/migrate.js + db/migrations/*.sql (ordered, transactional)
`
Login is email+password (scrypt-hashed) or "Continue with Google" (OAuth, env-gated), with session cookies (csd_session, 30 days, revocable server-side), email verification, password reset, and an in-memory login throttle. The first boot seeds an admin account from ADMIN_EMAIL + APP_PASSWORD and adopts all pre-multi-user data into it. Machine Basic auth (APP_USER/APP_PASSWORD) still works, but only on /api/* and /admin/run/* (the ops scripts) — it acts as the admin.
Roles: admin and member (users.role). Members never see the Jobs or Users pages, job-trigger buttons, or the System settings block; admins get everything plus the Users console (invite, comp plans, promote/demote, suspend, delete) and the waitlist. Admins have every feature unlocked without a subscription.
Plans (lib/plans.js, synced by Stripe webhooks): Free (5 watchlist coins, manual paper trading), Starter (10 coins + the paper auto-trader) and Pro (30 coins, auto-trader, premium on-chain feeds when they land). A lapsed subscription silently degrades to Free — no lockout, features just cap. past_due stays alive through Stripe's dunning window; comped is an admin-granted plan with no Stripe. Optional free trials (trial_days system setting) are limited to one per person ever via a trial_history tombstone that survives account deletion.
Billing (lib/billing.js): the Billing page shows the three plan cards with monthly ($4.99/$9.99) and yearly ($49.99/$99.99 ≈ two months free; env STRIPE_PRICE_*_YEAR, optional) checkout; upgrading opens Stripe Checkout (requires a verified email), and "Manage subscription" opens the Stripe customer portal (card, invoices, cancel, downgrade). Plan state is trusted only from signature-verified webhooks at POST /stripe/webhook — never from a redirect. Deleting an account (self-service in Settings, or admin) first stops the subscription at period end and fails closed if Stripe can't confirm.
Referrals ("give a month, get a month"): each user's Billing page mints a share link (/signup?ref=<code>); the code rides a 1-hour cookie through both the email form and the Google OAuth round-trip and is recorded as referred_by. When the referred user's subscription goes active (paid — trials don't count), the webhook credits both Stripe customers one month of Starter ($4.99) as a customer-balance credit via the referral_rewards ledger: per-side credited timestamps mean a failed Stripe call retries on the next qualifying webhook, Stripe idempotency keys make retries safe, self-referral is excluded, and legacy one-shot credits were backfilled (migration 013) so nothing pays twice.
Public landing page: anonymous visitors to / get a marketing page (pitch, a 5-minute-cached live teaser — coins scored, breadth, BUY count, Fear & Greed — a 7-day-delayed "recent signals" strip, plan cards with prices, signup CTAs) instead of a login wall. Logged-in users still land on their dashboard.
Public track record (/track-record, no login): the receipts page — BUY flips in the retained window (~13 months given prune retention; capped at the 2,000 newest) aggregated into 7-day/30-day forward returns, win rates, and average excess return vs holding BTC, with first/last dates and matured/pending counts shown, plus the individual rows (latest 30 on the page, the full set at /track-record.csv). Each row is labelled STRATEGY_VERSION/confighash — the code version plus a fingerprint of the runtime configuration (thresholds, gates, ranking membership) that produced it. Bar alignment: entry = the flip day's *previous* close; "+N days" = the bar whose close is exactly N periods after the entry bar; today's forming candle excluded everywhere. The live curve shows the locked model account (model@cryptosignaldeck.local: no password, no logins, comped Pro, watchlist auto-tracks the curated universe, campaign emails off, and even admins cannot suspend/delete/re-plan it) once it has ≥30 days of history AND at least one filled robot trade; until then the house account shows with an explicit may-include-manual-trades caveat. Cached 10 minutes.
Onboarding drip: the welcome/verify email now carries a 60-second tour; a day-3 check-in email (workers/onboarding.js, cron daily 15:30 UTC, job onboarding, once-ever flag onboard_day3_at) nudges the three highest-value setup steps (core dial, Telegram, backtest). Waitlist requesters get a confirmation email pointing at the track record and guide, keeping the pipeline warm while signups are closed.
Weekly digest (workers/digest.js, cron Sundays 16:00 UTC, job digest): one email per active user — pot value vs 7 days ago, every robot trade of the week, and which of their coins are currently BUY. Per-user toggle weekly_digest_enabled (default on).
Admin growth strip (top of the Users console): total users, new and active in 7 days, paying count, and MRR (live paid Stripe subscriptions only — comped plans and admins count $0; yearly plans normalised monthly).
What's shared vs per-user: market data, candles, quotes, and the daily signal scorecard are computed once and shared by everyone. Each user has their own watchlist, paper orders/fills/equity history, auto-trader toggle + risk tuning, alerts, display modes, and starting cash. Paper fees/slippage and the signal engine thresholds are system-wide (admin-set) so every account runs the same honest test.
Two layers. The coins table is the global registry that drives ingestion and signals — the union of everything any user watches (seeded with the top-10 majors: BTC, ETH, SOL, XRP, ADA, DOGE, LTC, LINK, AVAX, DOT). Each user's watchlist is which of those coins they follow and trade, capped by their plan's slots. Adding a coin (+ Add coin, any Coinbase USD pair, e.g. MATIC-USD) auto-registers it globally — new symbols are verified against Coinbase first so typos can't pollute the registry. Removing one takes it off *your* list (blocked while you hold a paper position in it); a coin left with no watchers and no positions is auto-disabled so workers stop ingesting it. Buys (manual and auto) are restricted to coins on your watchlist — an unwatched position would be invisible and unmanaged; sells of anything held are always allowed. New users are seeded with the 5 strongest coins by current signal score. BTC is flagged has_onchain (the only coin with reliable free on-chain data).
| Need | Source | Notes |
|---|---|---|
| Price, bid/ask (per coin) | Coinbase Exchange public API | no key, real prices, any product |
| Daily candles (per coin) | Coinbase Exchange public API | 300/request, paginated |
| Fear & Greed (market-wide) | Alternative.me | free, full history, context only |
| MVRV Z / SOPR / NUPL / Puell | BGeometrics | BTC only, ~15 req/day, cached daily |
Funding rates are reserved but disabled on the free tier (would need ~$29/mo Coinglass).
Crypto Signal Deck is built on generously free public data, credited with thanks:
None of these providers endorse this app; all analysis, scoring and any mistakes are ours. If a provider's terms change, the corresponding signal degrades gracefully (the pipeline treats missing data as absent, never guessed).
A handful of active scored signals (kept small on purpose — crypto has few cycles, so stacking more overfits):
1. Momentum (90-day return) — primary direction; strongest signal in the research.
2. Trend regime (price vs 200-day MA) — above = risk-on; below = new buys blocked. Plus 50/200 golden/death cross.
3. Relative strength (cross-sectional) — this coin's risk-adjusted momentum ranked 0–100 against the curated ranking universe (admin-set in_ranking coins, seeded with the 10 majors; BTC can't be removed). Coins outside the universe are measured *against* it without joining it, so one user adding a coin never shifts anyone else's rankings; every membership change is logged in ranking_history for point-in-time reconstruction. Scored AND gating (only coins at/above rs_buy_min_percentile may be bought).
4. MVRV Z-score — on-chain valuation overlay; BTC only. Cheap (z≤0.5) accumulate, stretched (z≥5) reduce. Omitted entirely for coins without on-chain data (they score on the others).
5. Realized volatility — penalises extreme vol and drives volatility-targeted sizing.
Context only (never scored): Fear & Greed (market-wide), drawdown from high, ATR, 7d/30d returns.
Signals are computed from completed daily bars only — the current UTC day's still-forming Coinbase candle is excluded everywhere (signals, backtests, charts). The buy gates fail closed: a coin without 200 days of history has no 200DMA regime check, so new buys are blocked until it does; likewise a missing relative-strength rank blocks buying rather than waving it through.
Alt gates (added 2026-07 after the churn study — they took the whole-system backtest from −35% to breakeven over 5.5 years): non-BTC coins face two extra buy gates, both admin-toggleable in System settings and both failing closed when BTC data is missing. (1) BTC regime: no alt buys while Bitcoin is below its own 200-day average — when the market leader is risk-off, alts get hit harder. (2) Alt-vs-BTC: an alt is only buyable when its 90-day return beats Bitcoin's — it has to *earn* its place over just holding BTC. Blocked buys show the exact reason in "Why this signal?". BTC itself is exempt from both.
Composite score is the sum of component points (roughly −3..+3). The decision engine maps it to an action:
BUY_CANDIDATE — score ≥ buy threshold and above the 200DMAHOLD — neutral, or a buy signal blocked by the regime gateREDUCE — mildly negativeSELL_CANDIDATE — score ≤ sell thresholdNO_TRADE — not enough historyBefore any per-coin buy, assessBuy() checks: stale price, wide spread, below-200DMA regime, total-equity drawdown stop, daily-loss limit, per-coin post-stop cooldown, per-coin allocation cap (max_coin_allocation_pct) and total exposure cap (max_total_allocation_pct). It then volatility-targets the position size and caps it by max-trade %, remaining per-coin/total room, and the user's hold-back reserve (autotrade_holdback_pct — cash the robot may never deploy). Stops are checked per position by checkStopLoss(); the user's BTC core (if set) is exempt from every exit. Every block/stop is logged to risk_events with its symbol (see the Risk page).
Each user has one USD cash balance funding positions across their watchlist coins. Orders + fills are an immutable per-user log; the portfolio is derived by replaying that user's fills with average-cost accounting per symbol. Market orders fill immediately at the latest price ± slippage; limit orders rest until that coin's price crosses (checked on each new quote, re-validated against the owner's cash/holdings at fill time). Fees and slippage are system-wide (admin-set: paper_fee_bps, paper_slippage_bps) so everyone runs the same honest test; starting cash is per-user. The daily snapshot tracks each user's equity vs a buy-and-hold-BTC baseline that starts when their account did.
/backtest?symbol=ETH-USD runs the same signal + decision logic day-by-day over one coin's stored daily candles (no look-ahead — each day only sees data up to that day), applies fees/slippage, and reports CAGR, max drawdown, Sharpe, win rate, average trade, fees paid, and outperformance vs buy-and-hold. It uses your current Settings (thresholds, sizing, fees) and includes the same adaptive exits the auto-trader runs — fixed stop-loss, ATR trailing stop, signal flip, and the 50DMA-break / momentum-rollover reduces — via the shared pure engine in lib/exits.js. The cross-coin relative-strength gate can't apply to a single-coin test. Pick the coin from the dropdown. MVRV is included only for BTC. Run npm run ingest first to load history; npm run backtest runs BTC from the CLI.
Whole-portfolio backtest — pick 📊 Whole portfolio in the dropdown (or node workers/backtest_cli.js portfolio). This simulates the FULL system: relative strength is ranked over the global enabled registry (the same universe the live engine ranks) while buys are restricted to your own watchlist — exactly what your live account does. Coins trade from one shared cash balance (so the RS buy gate applies), sized vol-targeted under the per-coin/total allocation caps, with the adaptive exits, post-stop cooldowns, and the portfolio-level drawdown-stop and daily-loss buy blocks. Reports the equity curve vs HODL-BTC and the equal-weight basket, plus per-coin trade results. Not modelled (needs intraday data): the stale-price and bid/ask-spread gates. Pure engine: lib/portfolio_backtest.js.
Shadow challenger race (admin-only, /admin/shadow) — how strategy changes earn their way into the live engine. A candidate improvement is frozen as a named challenger (currently K = J + blended momentum acceleration, pre-registered 2026-07-21), then raced against the live config daily: the shadow job replays both through the pure backtester on identical candles, timestamps, and cost assumptions. Experiment validity: both engines start flat (identical state) on the freeze date; race rows are insert-only and stamped with the experiment's config hash (later code/data changes can't rewrite past evidence); and the tradable + curated-ranking universes are frozen at their freeze-date membership. Each day appends genuinely unseen data — a prospective test, not another backtest of the same history. Pre-registered promotion rule (all must hold): ≥90 race days · ≥1 risk-on episode (BTC above its 200DMA for 10+ consecutive days) · ≥10 divergent days (>1bp daily difference) · challenger leads by ≥2 percentage points · challenger's max drawdown no more than 2 points worse. Parameters can't be touched mid-race — a tweak restarts it. Until promotion, live signals, auto-trading, and the public track record are unaffected.
Three switches at the top of Settings:
tutorial_enabled) — the getting-started overlay for new visitors. Replay anytime via the "▶ Replay the intro now" link on Settings or /?intro=1.plain_english_enabled, default on) — every main page opens with a green box written from that page's live data: what your pot is worth, which coins look worth buying, why the app is sitting in cash, where positions would auto-sell, whether the jobs are healthy, what a backtest result means. Each box has a one-click "hide these explanations" button; turn them back on from the Dashboard footer or Settings.simple_mode_enabled, default off) — hides the advanced surface: the menu trims to Dashboard · Alerts · Trades · Billing · Settings (Backtest/Risk still work by URL), the watchlist shows "N good signs · M cautions" instead of raw RS/score numbers, and limit orders, open orders and the per-signal points table are tucked away. Everything still runs — it's just out of sight.The FAQ, Quick Start and this Manual are linked top-right on every page (visible in Simple mode too). The app lives at cryptosignaldeck.cloudproadvisor.com. These three docs are kept in lockstep with the app — any feature or rule change updates them in the same commit.
The top menu is sticky (stays visible while you scroll). Top-right also shows the running version (the deployed git build); every page quietly polls /version and shows a pulsing 🔄 Update button when a newer build has been deployed — click it to reload onto the new version.
The Settings page itself is grouped into plain-language sections (Display, Selling rules, Paper auto-trader, Risk limits, Paper account & notifications — plus the admin-only System block: signal engine, alt gates, execution discipline, paper realism, platform) with hints under anything unobvious; numbers are clamped to sane ranges on save.
Every held position shows where the system would sell it: the watchlist has a Stop column (the nearest hard-exit level — fixed stop vs trailing stop, whichever a falling price hits first — plus the distance to it), and each coin page shows an Auto-sell at card with both levels. Levels come from the same pure exit engine the auto-trader uses, so what you see is exactly what would execute.
Alerts are per-user: signals are computed once, then every user *watching* the coin gets their own alert row, email (to their own address, honouring email_alerts_enabled) and Telegram message (once connected, honouring telegram_alerts_enabled). Telegram linking lives in Settings → Paper account & notifications: a one-time 30-minute t.me deep link pairs the chat; /stop (or the Disconnect button) unpairs it. The bot needs only TELEGRAM_BOT_TOKEN (from @BotFather) — the webhook self-registers at boot and is authenticated by a token-derived secret header. Two alert kinds, shown on each user's Alerts page:
BUY_CANDIDATE (its previous snapshot was something else), each watcher gets one email. Staying in BUY across days does not re-alert; re-entering BUY does. Runs as the alerts step in the daily batch (after signals).Resend also carries the account emails (verify, password reset, invites). Set RESEND_API_KEY and a verified-domain ALERT_EMAIL_FROM (in production the app refuses to boot on the default resend.dev sender — it only delivers to the account owner); ALERT_EMAIL_TO is where owner notifications (waitlist requests) go. The admin's Send test email button on the Alerts page verifies delivery.
In-process node-cron, all UTC (crypto trades 24/7):
ingest_quote (live price tile + fills resting limit orders, all users)daily batch: ingest candles → fear&greed → on-chain → signals → alerts (per user) → auto-trader (loops eligible users) → paper snapshot (loops all users)prune retentionThe auto-trader runs for every non-suspended user whose plan includes it and whose autotrade_enabled is on, against their own portfolio and risk settings. Two system-wide execution rules (same churn study; both backtesters mirror them): soft exits trim once per episode — a 50DMA-break or momentum-rollover trims 25% when it *appears* (only a FILLED trim counts), then holds while it persists and re-arms when it clears or the position goes flat (soft_exit_one_shot; per-user state in autotrade_soft_done); and an optional add cooldown — autotrade_add_cooldown_days between adds to a held coin (default 0/off: the 10-day version reduced returns in every window tested). Pause everything with the checks_paused system setting. Trigger any job manually from the Jobs page (admin) or node workers/run.js <job>.
`bash
cp .env.example .env # set DATABASE_URL (Postgres)
npm install
npm run migrate
npm run ingest # load candles + price
npm run signals # compute first signal
npm start # http://localhost:3000
`
Push to GitHub, create a Railway project + Postgres, set env vars from .env.example. railway.json runs npm run migrate pre-deploy and npm start. Health check is /health. Set ENABLE_WORKERS=false on a second service if you ever want to split web and workers.
1. Implement lib/broker/coinbase.js order methods using a trade-only CDP key.
2. Set LIVE_TRADING_ENABLED=true and LIVE_BROKER=coinbase in env.
3. Add and use the in-app second confirmation (broker_connections.confirmed_at).
4. Prefer limit orders. Keep spot-only (UK FCA bans retail crypto derivatives; as a US person, avoid offshore venues).
Settings live in two places (see the Settings page):
user_settings, lazy defaults): display modes (tutorial_enabled, plain_english_enabled, simple_mode_enabled), the auto-trader (autotrade_enabled, reduce_fraction_pct, autotrade_max_positions, autotrade_holdback_pct, autotrade_manage_manual, btc_core_pct), exits (stop_loss_pct, atr_trailing_mult, exit_on_50dma_break, exit_on_momentum_rollover), risk limits (max_trade_pct, max_coin_allocation_pct, max_total_allocation_pct, max_daily_loss_pct, max_drawdown_stop_pct, cooldown_hours, max_spread_bps, stale_price_minutes, vol_target_annual_pct), paper_starting_cash, email_alerts_enabled, telegram_alerts_enabled.Auto-trader budget controls (mirror the Schwab app): autotrade_holdback_pct reserves that % of the pot as cash the robot never touches — it's yours for manual trading (0 = robot may use everything; 100 = robot never buys); the reserve only ever holds back cash, it never sells your positions to top itself up. autotrade_max_positions caps how many coins the robot will hold at once. autotrade_manage_manual (default on) decides whether the robot's exit rules also protect coins you bought by hand — off means it only ever sells positions it opened itself (origin = whether the opening buy's order id starts with auto-).
BTC core — the personal risk dial (btc_core_pct, default 0, experimental): buy-and-hold Bitcoin worth that % of the pot, never sold by the robot — exempt from every exit; the strategy trades its usual satellites around it. Tested on 5½ years + rolling windows: 25% was the only setting that consistently improved returns (~+8 pts median year, ~10 pts deeper crashes); higher settings mostly track Bitcoin, and every setting still lost in the 2025 chop. Mechanics: raising the dial first converts Bitcoin the user already holds into core (no purchase, no fees), then buys only the remaining shortfall — from cash above the hold-back reserve, only on a fresh price, one order per attempt under a revision id (auto-core-u<id>-r<n>), and the established mark advances only when the target is actually met (partial/blocked/rejected buys retry daily). Lowering the dial releases the excess back to the strategy without selling. Accounting is a two-bucket lot engine (splitCoreSatellite in lib/portfolio.js, pure + unit-tested): core buys land in the core bucket by their order ids, sells consume the satellite first (overflow heals the core), conversions move quantity at the *satellite's* basis, releases at the *core's* — so exits always see the satellite's true cost basis, never a blend. State is one atomic JSON key btc_core_state {est, rev, events} (quantities always derived; legacy states auto-adopted). Requires BTC on the watchlist (auto-added on save). Note: the dashboard's Stop column shows exit levels for the full position, but the robot only ever sells the satellite portion.
settings, admin-only): the shared signal engine (buy_score_threshold, sell_score_threshold, rs_buy_min_percentile, signal_decay_days), the alt gates (btc_regime_gate, alt_vs_btc_gate), execution discipline (soft_exit_one_shot, autotrade_add_cooldown_days), paper realism (paper_fee_bps, paper_slippage_bps), and platform switches (signups_enabled, trial_days, checks_paused).