Skip to content

signalbetorg/crypto-signal

Repository files navigation

Crypto Signal App

Screenshot_2

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."


Key Features

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

Tech Stack

Frontend

  • Next.js 16.1.6 (App Router), React 19, TypeScript 5, Tailwind CSS 4
  • lightweight-charts v5 — 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)

Architecture

Signal Flow

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)

Project Structure

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

Engineering Deep Dive

Six non-trivial system design problems encountered and solved during development.


Challenge 1: Unified Multi-Exchange Adapter with Binary WebSocket Support

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.


Challenge 2: REST + WebSocket Hybrid Candle Feed Without Stale State

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.


Challenge 3: Preventing Chart Re-renders on Every Price Tick

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.


Challenge 4: Stripe Webhook Tier Enforcement with RLS Write Protection

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.


Challenge 5: Multi-Strategy Signal Deduplication on a Background Scanner

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).


Challenge 6: Per-User Signal Outcome Tracking with Expiry Pricing

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.


Omitted Files

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

Installation & Setup

Prerequisites

  • 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.

1. Clone and install

git clone https://github.com/Taehoonryu04/Crypto_Signal_App.git
cd Crypto_Signal_App
npm install

2. Configure environment variables

Create .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:3000

3. Run Supabase migrations

Run all 11 SQL files in supabase/migrations/ in order via the Supabase SQL Editor.

4. Run

npm run dev     # http://localhost:3000
npm run build   # Production build
npm start       # Production server

License

© 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.

About

production grade real time trading signal platform on binance, bybit, coinbase and upbit

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages