A production-grade real-time cryptocurrency trading signal platform. Connect to Binance, Bybit, Coinbase, or Upbit and receive live BUY/SELL/WATCH signals generated by a proprietary multi-timeframe algorithm — with confidence scores, target prices, stop-losses, and Web Push delivery to pro subscribers.
Built to demonstrate production-grade full-stack system design — not just "display a price ticker."
| Feature | Description |
|---|---|
| Multi-exchange WebSocket | Live price and candlestick streams from Binance, Bybit, Coinbase, and Upbit via a unified adapter pattern |
| Proprietary Signal Engine | Multi-timeframe BUY/SELL/WATCH signals (5m/15m/1h) with two independent strategies: breakout and EMA pullback |
| 90-Day Backtest Engine | Full replay with parameter sweep, trigger sweep, and per-confidence-tier win rate breakdown |
| Signal History & Outcomes | Per-user WIN/LOSS/EXPIRED tracking with stats panel, outcome prices, and strategy sub-labels |
| Web Push Notifications | VAPID-based push delivery gated behind Pro tier; server-side fanout via background Railway scanner |
| Stripe Subscriptions | Checkout, Billing Portal, and webhook-driven tier sync with RLS-enforced write protection |
| Background Scanner | Separate Railway server continuously polls 15 coins across all 4 exchanges, runs the signal generator on every poll, and deduplicates via a unique DB index — guaranteeing each signal fires exactly once per candle |
| Auth | Supabase email/password + GitHub OAuth with SSR-safe session handling and route protection |
Frontend
- Next.js 16.1.6 (App Router), React 19, TypeScript 5, Tailwind CSS 4
lightweight-chartsv5 — candlestick chart with live price overlay
Backend & Infrastructure
- Supabase (PostgreSQL + RLS + SSR auth)
- Stripe (Checkout + Billing Portal + webhooks)
- Web Push API (
web-push, VAPID) - Background scanner: Node.js on Railway (separate private repo)
- Deployment: Vercel (Next.js app) + Railway (scanner)
Railway Scanner (background)
└─ continuously polls all 4 exchanges' REST APIs (sequential round-robin per coin)
└─ runs signal generator on every poll (breakout + EMA pullback)
└─ INSERT coin_signals (dedup: symbol × exchange × timestamp × strategy)
└─ fanout.ts: query pro subscribers by exchange
├─ Web Push → each subscriber's browser
└─ INSERT signal_history (per-user outcome tracking)
Next.js App (Vercel)
└─ WebSocket adapters → useLivePrice + useCandles
└─ useSignal (client-side signal hook, same generator)
└─ LivePrice.tsx (chart + signal banner + indicators)
Backtest
└─ /api/backtest → fetchHistoricalCandles (Binance REST, 90 days)
└─ runBacktest → iterates 15m candles, scores by confidence tier
└─ /backtest page (parameter sweep + trigger sweep UI)
app/
├── components/ LivePrice.tsx, CandleChart.tsx
├── api/
│ ├── backtest/ 90-day replay endpoint (30s Vercel timeout)
│ ├── push/ subscribe + send (VAPID)
│ ├── stripe/ checkout, webhook, portal, sync, test-downgrade
│ ├── history/ CSV export (pro only)
│ └── account/ DELETE (profile → auth user, service role)
├── backtest/ Parameter sweep + trigger sweep + EMA pullback UI
├── history/ Signal history with WIN/LOSS/EXPIRED badges
└── settings/ Exchange, trading type, push toggle, Stripe billing
hooks/
├── useLivePrice.ts Wraps ExchangeAdapter for ticker stream
├── useCandles.ts Rolling 100-candle window; WS for Binance, REST poll for others
├── useSignal.ts Memoized signal + 20-entry dedup history
├── useProfile.ts Supabase profile, tier, exchange, updateExchange()
├── usePushNotifications.ts SW registration, subscribe/unsubscribe
└── useSignalHistory.ts Paginated signal history; free = 10 BTC-only rows
lib/
├── exchanges/
│ ├── base-adapter.ts Abstract BaseAdapter — exponential backoff reconnect
│ ├── binance / bybit /
│ │ coinbase / upbit / Exchange adapters (ticker + kline)
│ ├── normalize.ts toExchangeSymbol(), isSupported(), getExchangeLabel()
│ └── types.ts Candle, ExchangeAdapter, KlineInterval, PriceUpdate
├── signals/
│ ├── types.ts SignalType, Signal, SignalFeatures interfaces
│ └── generator.ts ← excluded (see Omitted Files)
├── indicators/ ← excluded (see Omitted Files)
├── backtest/ ← excluded (see Omitted Files)
└── supabase/ Browser + server Supabase clients, middleware
supabase/migrations/ 11 incremental SQL migrations
public/sw.js Service worker — push event → showNotification
Six non-trivial system design problems encountered and solved during development.
Problem. Each exchange uses a different WebSocket protocol: Binance uses plain JSON streams, Bybit requires a subscription message + 20s ping keepalive, Coinbase requires a JSON subscribe frame on connect, and Upbit sends binary ArrayBuffer data instead of text. Building per-exchange spaghetti would make adding or modifying any exchange a full rewrite risk.
Solution. lib/exchanges/base-adapter.ts defines an abstract BaseAdapter with exponential backoff reconnection (max 5 retries, capped delay) and two hook points — afterConnect() and afterKlineConnect() — that subclasses override to send subscription frames without touching the connection logic. Binary WebSocket support is handled once in BaseAdapter: binaryType = 'arraybuffer' is set on every socket, and the onmessage handler runs incoming data through a TextDecoder before passing it to parseMessage(). UpbitAdapter.parseMessage() receives a plain string and needs no special handling. Adding a new exchange requires only a new file extending BaseAdapter — the reconnection, keepalive, and binary handling are inherited automatically.
Problem. Binance provides a kline WebSocket stream that pushes every tick of the current open candle. Bybit, Coinbase, and Upbit provide no kline WebSocket — only REST endpoints. Naively combining both approaches produces two failure modes: (a) Binance's open candle gets duplicated if the REST snapshot is not trimmed, and (b) Coinbase/Upbit show a frozen last candle between 15-second polls.
Solution. hooks/useCandles.ts diverges by adapter type. For Binance, the REST snapshot drops its last entry (data.slice(0, -1)) and the WebSocket stream takes over the live open candle — same time → replace, new time → append. For Bybit, Coinbase, and Upbit, all REST candles are kept (no slice), and a displayCandlesLive memo in LivePrice.tsx overlays the current ticker price onto the last candle's close between polls. This produces smooth price movement on chart for all four exchanges with a single unified CandleChart component.
Problem. CandleChart.tsx calls series.setData() to render candlestick data. The live ticker fires at ~1s intervals via WebSocket. Without memoization, every price tick triggers a full chart redraw — setData() on 100 candles at 1Hz causes visible jank and unnecessary GPU work.
Solution. CandleChart is wrapped in React.memo. The candle array prop is only updated on actual new candles (15m intervals), not on price ticks — the ticker overlay is computed in displayCandlesLive and passed as a separate stable memo. A subtler issue: last5History (signal markers passed to the chart) was initially computed as an inline .slice().reverse(), which creates a new array reference on every render and silently bypasses React.memo. The fix is useMemo(() => history.slice(-5).reverse(), [history]) — producing a stable reference that only changes when signal history actually changes.
Problem. Stripe webhook events (checkout.session.completed, customer.subscription.deleted) must update the tier and stripe_customer_id columns in Supabase profiles. But these same columns must be read-only to the anon client — otherwise any authenticated user could self-upgrade their tier by calling the Supabase client directly.
Solution. Two independent layers enforce this. First, supabase/migrations/007_restrict_tier_update.sql adds an RLS policy that blocks any UPDATE touching tier or stripe_customer_id from the anon key — regardless of which row. Second, app/api/stripe/webhook/route.ts instantiates a Supabase client inline using SUPABASE_SERVICE_ROLE_KEY (bypasses RLS entirely), never using lib/supabase/server.ts (which uses the anon key with cookies). The webhook also reads the raw body via req.text() rather than req.json() — Stripe's HMAC signature verification requires the exact raw byte sequence, which req.json() would destroy by re-serializing.
Problem. The Railway scanner polls continuously in a sequential round-robin loop and must fire a signal exactly once per candle per strategy — never duplicating on subsequent polls within the same candle window, and never conflating signals from two different strategies (breakout vs. EMA pullback) on the same candle.
Solution. coin_signals has a unique index on (symbol, exchange, timestamp, strategy). The scanner calls both generateSignal() and generateSignalEMAPullback() on every poll and routes each through a processSignal() helper that attempts an INSERT — PostgreSQL silently rejects duplicates on the unique index. The first poll that meets signal conditions fires fanout and trade execution; all subsequent polls within the same 15m candle are no-ops at the DB layer. The strategy column in the index means both strategies can fire independently on the same candle timestamp without collision. signal_history carries the same strategy column so the UI can render per-row strategy sub-labels (EMA pullback vs. breakout).
Problem. Standard signal tracking logs whether a signal fired — but not whether it was correct. For a trading signal app, the key metric is whether the price actually moved to the target (WIN), hit the stop-loss (LOSS), or expired with the position neither won nor lost (EXPIRED). EXPIRED signals require special handling because the outcome price has directional meaning — slightly above entry is a different result for a BUY than for a SELL.
Solution. signal_history rows are inserted by fanout.ts at signal fire time with outcome = 'PENDING'. The expiry monitor checks open positions every 60 seconds and resolves outcomes: WIN if price moved ≥ target direction, LOSS if stop-loss was hit, EXPIRED otherwise. The history UI (app/history/page.tsx) applies directional coloring to EXPIRED outcome prices — emerald if the price moved favorably (within ±0.01% zone relative to entry), red if it moved against — giving users a quick read on near-miss signals without misrepresenting them as wins or losses.
The following files are excluded from this repository to protect proprietary trading logic. The repository will not compile as-is — these are required dependencies of hooks/useSignal.ts, app/api/backtest/route.ts, and app/backtest/page.tsx.
| Path | What it contains |
|---|---|
lib/signals/generator.ts |
Core signal algorithm — multi-timeframe entry conditions, two independent strategies, all filter thresholds and parameters |
lib/indicators/ |
All technical indicator implementations used by the signal generator and backtest engine |
lib/backtest/engine.ts |
Backtest simulation engine — trade result scoring, win/loss evaluation, confidence tier breakdown |
lib/backtest/sweep.ts |
Parameter sweep and trigger sweep factory functions |
lib/backtest/fetchHistorical.ts |
Paginated Binance REST historical candle fetcher |
scripts/ |
Internal backtesting scripts and ML training data export pipeline |
- Node.js 20+
- Supabase project (PostgreSQL + auth)
- Stripe account with a subscription product
- VAPID key pair (generate via
web-push generate-vapid-keys)
Note: This repo is shared for architecture and code review. The omitted algorithm files are required to run the signal and backtest features. The exchange adapters, auth, push, and Stripe flows are fully present.
git clone https://github.com/Taehoonryu04/Crypto_Signal_App.git
cd Crypto_Signal_App
npm installCreate .env.local at the project root:
# Supabase
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Web Push (VAPID)
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:you@example.com
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_PRO_PRICE_ID=
NEXT_PUBLIC_SITE_URL=http://localhost:3000Run all 11 SQL files in supabase/migrations/ in order via the Supabase SQL Editor.
npm run dev # http://localhost:3000
npm run build # Production build
npm start # Production server© 2026 Taehoonryu04. All rights reserved.
The source code is publicly visible for portfolio and recruiting review. No permission is granted to use, copy, deploy, or redistribute this project without explicit written consent.