🎧 Listen to the manual — nine short chapters, about 20 minutes in all
Press play and the page jumps to that section, so you can read along. Chapters cover the member-facing sections; the developer sections (architecture, running locally, deploy) aren't narrated.
Crypto Signal Deck — Manual
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).
---
Safety model (read this first)
- Default mode is PAPER. Every order goes through the
PaperBrokerAdapter. No real funds, no exchange keys needed. - Live trading is hard-disabled.
LIVE_TRADING_ENABLED=falseis the default env guard. The broker factory refuses to construct a live adapter unless it'strue. - A second confirmation is required. Even with the env guard on, a live order needs
broker_connections.confirmed_atset (an explicit in-app confirmation, not yet exposed in the UI). - No live order code exists yet.
CoinbaseAdapter.placeOrderthrows by design. Read-only public price data is the only live thing it does. - Keys are trade-only. When you eventually add exchange keys, create them trade-only with withdrawals/transfers disabled, and store them only in environment variables — never in the browser or the database.
- Idempotency. Every order carries a
client_order_id; duplicates are no-ops.
---
Architecture
`
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)
`
Accounts, roles & plans
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.
Getting-started email series (workers/onboarding.js, cron daily 15:30 UTC, job onboarding): a short how-to each morning for the first week (welcome → Bitcoin core → Telegram → reading the signals → the robot's rules → backtesting → the Today page & help), then one tip a week for a month (the 2-minute routine, display toggles + tour, the Sunday digest, checking the public track record). One step per user per run AND at least 20 hours since the previous send (a manual rerun can't march an account through the week in one afternoon); weekly steps also wait gapDays since the previous send. The pure half (steps, pacing, renderer) lives in lib/onboarding_steps.js. Progress is per user in user_settings (onboarding_sent = stable step ids, onboarding_last_sent_at); accounts younger than 30 days auto-enrol, older ones only via onboarding_enroll. Verified emails only. Opt-out: the ✉️ Settings checkbox (onboarding_emails_enabled) or the signed one-click unsubscribe in every footer (/unsubscribe, RFC 8058 headers via lib/unsub.js; the weekly digest carries the same). Members who received the old day-3 check-in are treated as having had the welcome. Waitlist requesters get a confirmation email pointing at the track record and guide.
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).
Auto-trader execution discipline (engine 2026.08-J2): each run processes all exits first (freeing cash), then buys — and the day's remaining allocation room is split equally across that day's buy candidates, so which coin gets capital never depends on ticker alphabet or being the hottest scorer. The auto-trader also only trades on the latest complete signal run: if any coin's signal computation failed, that pass is never traded (no mixed-vintage days). When the signal pipeline is degraded (no valid run), every automated buy pauses — including BTC core establishment buys — while held positions keep their fresh-price fixed-stop protection; everything retries the next day.
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.
Coins / watchlist
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).
Data sources (all free tier)
| 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).
Data credits & attribution
Crypto Signal Deck is built on generously free public data, credited with thanks:
- Market prices and candles — Coinbase Exchange public market data API.
- Crypto Fear & Greed Index — Alternative.me, who ask for (and deserve) attribution when their index is displayed.
- Bitcoin on-chain metrics (MVRV Z-score and friends) — BGeometrics / bitcoin-data.com.
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).
The signal scorecard (computed per coin)
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 history
Risk engine
Before 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).
Paper trading
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.
Backtesting
/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, the max-positions and cash hold-back limits, and the portfolio-level drawdown-stop and daily-loss buy blocks. Simulator honesty (2026-08 parity review): fills execute at the next bar's open (live trades minutes after the close, not a day later), all exits run before any buy, each day's allocation room is split equally across buy candidates (capital priority can't depend on ticker names), and on-chain data arrives with the same ~2-day delay the live engine experiences — no information from the future. 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 plus a chained hash of the engine-consumed candle fields (later code/data changes can't rewrite past evidence — and late-arriving MVRV/F&G values can't fake drift, a lesson from the first race's false halt); observations lag one day so every stored bar has survived a refetch; 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.
Display modes (mirrors the Schwab Signal Deck)
Three switches at the top of Settings:
- 🧭 Guided tour (
tutorial_enabled, default on) — step-by-step pop-ups anchored to the real controls, page by page: Dashboard (pot, watchlist, signal, stop column, Movers, trading, the equity chart) → Today → Alerts → Trades → Settings (Bitcoin core, robot, notifications). Runs automatically the first time a member opens each page (progress per page in the browser,csd_tour_<page>); Skip, or Done at the end of the flow, also turns the setting off server-side so it stays quiet on every device. Replay the whole flow anytime with "▶ Replay the whole tour now" on Settings, the Dashboard footer link, or/?tour=1(the old/?intro=1link does the same). Replaces the one-shot welcome overlay. - 💬 "In plain English" summaries (
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 (
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.
Help & docs
The FAQ, Quick Start and this Manual are linked top-right on every page (visible in Simple mode too; on phones they move into the ☰ menu panel). 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.
Listen instead of read (2026-09-06): the Quick Start page carries a ~6-minute audio version, and this Manual can be played chapter by chapter from the "🎧 Listen to the manual" panel — pressing play scrolls the page to that chapter's section (read-along) and pauses any other chapter. Files live in web/public/ (served publicly — member-guide content only, generated with the DeepGram podcast CLI from condensed member-view narration, loudness-verified); nothing downloads until you press play.
The menu (mirrors the stocks Signal Deck): Dashboard · 📅 Today · Alerts · Trades · Backtest · Risk · Billing · ✨ What's new · Settings, with the admin-only Jobs/Users/Shadow folded into one Admin ▾ dropdown (hover, keyboard focus, or tap). Below ~920px the whole row collapses into a single ☰ Menu button whose panel lists everything (the Admin group flattens into a labelled section, and the Help links join it); data tables scroll inside themselves on narrow screens instead of dragging the page sideways, touch targets are ≥44px, and a "Skip to content" link serves keyboard/screen-reader users. The app is installable: a web manifest, SVG icon and theme colour mean "Add to Home Screen" opens it standalone on 📅 Today.
✨ What's new (web/whatsnew.js): each batch of member-facing changes is announced ONCE as a pop-up (per release, per member — dismissed by Close, backdrop, following a link, or visiting the page; stored as a per-release user_settings key so old tabs can never acknowledge a release they didn't display), and permanently on /whats-new. Accounts created on or after a release's date never see its pop-up (nothing is "new" to them). The pop-up stands down while the guided tour is running. To announce a batch, prepend a release to RELEASES — its id becomes the current release automatically.
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.
Dashboard extras
- 📅 Today (
/today) — the member's last 24 hours in one read: coins that flipped to BUY (their own alert rows), the biggest movers on their watchlist vs yesterday's daily close (00:00 UTC — exactly yesterday's completed candle; a coin whose candle or fresh quote is missing shows no figure and sorts last), and their paper trades with a 🤖 robot / 🙋 you tag (robot =client_order_idstarts withauto-), plus the market-breadth backdrop line. Quotes older than a day are excluded so a stalled feed can never masquerade as "today". - 📈 Movers view (
/?view=movers) — the watchlist re-ranked by the size of today's move (vs yesterday's close), either direction; the 24h column is now on the watchlist in both views. - Market breadth card — how many watchlist coins are above their 200-day average (regime context; broad weakness = few coins qualify to buy anyway).
- Data as of chips — the age of the candles / MVRV / Fear & Greed behind the current signals, flagged ⚠ at the same thresholds the pipeline's staleness guards use.
- The equity curve plots three lines: the strategy, HODL-BTC, and an equal-weight watchlist basket (starting cash split across the enabled coins at the first snapshot) — the honest "just buy everything" benchmark.
- The coin page's price chart has range buttons — 1D · 7D (hourly bars) · 30D · 90D · 180D (default) · 1Y · All (completed daily bars) — and ends with a hollow dot for the live price (today's still-forming value) so today's move is on the chart rather than only in the 24h column. Honesty rules: bars are loaded by a time window, not a row count, and plotted by time (a feed gap shows as a gap, never compressed); the live dot is drawn only while the quote is younger than
stale_price_minutes— a stale quote gets an explicit "last quote … ago" note instead of a "live" label; the chart section re-fetches itself every 45 s (/c/:symbol/chart) alongside the price tiles so the dot, dashed segment and scale track the refreshed quote; the page ALSO expires the live indicator locally from the quote timestamp embedded in the markup, so a failing refresh (outage, offline) can never leave a stale dot labelled live; the refresher follows no redirects and only swaps in a body carrying the fragment marker, so an expired session shows a "log in again" note rather than a login page inside the chart; and consecutive bars more than 1.5 bar-widths apart start a new line segment — a feed hole is a visible break (counted in a "gaps in the data" pill), never a solid bridge. Signals never read the hourly bars or the live point: they see completed daily bars only. The chart also shows buy/sell markers for every fill. - /trades — the full order history (manual + auto-trader) with fill prices and fees, latest-first with the shared date filter.
Exit visibility
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.
Email & Telegram alerts
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 flips — when a coin's latest signal flips into
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 thealertsstep in the daily batch (aftersignals). - Executed auto-trades — when *your* auto-trader actually sells (stop-loss / trailing stop / signal flip), reduces, or buys, you get a trade-confirmation email with size, price and the reason. Keyed to the order, so a re-run can never email the same trade twice.
The two bulk streams — the Sunday digest and the getting-started series — carry a signed one-click unsubscribe (footer link + RFC 8058 List-Unsubscribe headers) so mail providers never punish the transactional mail sent from the same domain; alerts you asked for, verification and password emails carry none by design. 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.
Scheduling
In-process node-cron, all UTC (crypto trades 24/7):
- Every minute —
ingest_quote(live price tile + fills resting limit orders, all users) - Seven past every hour —
ingest_hourly(chart-only hourly candles, 300 bars per coin ≈ 12.5 days, self-healing; pruned after 35 days) - 00:05 UTC daily —
dailybatch: ingest candles → fear&greed → on-chain → signals → alerts (per user) → auto-trader (loops eligible users) → paper snapshot (loops all users) - 03:00 UTC daily —
pruneretention - 15:30 UTC daily —
onboarding(one getting-started step per eligible user); Sundays 16:00 UTC —digest
The 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>.
Running locally
`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
`
Deploy (Railway)
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.
Going live later (not built yet)
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 reference
Settings live in two places (see the Settings page):
- Per-user (
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,weekly_digest_enabled,onboarding_emails_enabled(plus the series' bookkeeping keysonboarding_sent,onboarding_last_sent_at,onboarding_enroll, and the per-releasewhatsnew_seen:<id>acknowledgements).
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.
- System (
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).