TRON Parity Ladder — price parity & market cap flip dashboard
This commit is contained in:
commit
c690f01dd2
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@ -0,0 +1,47 @@
|
||||
# ============================================
|
||||
# Docker build context exclusions
|
||||
# ============================================
|
||||
|
||||
# Dependencies & build cache
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Previous builds
|
||||
dist
|
||||
|
||||
# Git & editor
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Docker (nested builds not needed)
|
||||
docker-compose*.yml
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Docs / misc
|
||||
README.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
*.md
|
||||
|
||||
# Tests & CI
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
tests
|
||||
coverage
|
||||
.nyc_output
|
||||
cypress
|
||||
playwright
|
||||
|
||||
# Local env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
||||
# ============================================
|
||||
# TRON Parity Ladder — Environment Configuration
|
||||
# ============================================
|
||||
# Copy this file to .env and customize:
|
||||
# cp .env.example .env
|
||||
# ============================================
|
||||
|
||||
# Port the container exposes on the host (default: 8080)
|
||||
PORT=8080
|
||||
|
||||
# Optional: override the timezone inside the container
|
||||
TZ=UTC
|
||||
|
||||
# Optional: image tag override (set via docker-compose build --build-arg)
|
||||
# IMAGE_TAG=latest
|
||||
198
DOCKER.md
Normal file
198
DOCKER.md
Normal file
@ -0,0 +1,198 @@
|
||||
# 🐳 Docker Deployment — TRON Parity Ladder
|
||||
|
||||
Production-grade containerization for the TRON Parity Ladder dashboard. Multi-stage build produces a minimal (~25 MB) Alpine Nginx image that serves the static bundle with gzip, caching headers, and SPA routing.
|
||||
|
||||
---
|
||||
|
||||
## 📁 What's included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile` | Multi-stage build: Node 20 → Nginx 1.27 Alpine |
|
||||
| `docker-compose.yml` | Service definition, healthcheck, resource limits |
|
||||
| `docker/nginx.conf` | Production Nginx config (gzip, CSP, SPA routing, caching) |
|
||||
| `.dockerignore` | Keeps build context lean (~500 KB) |
|
||||
| `.env.example` | Environment variable reference |
|
||||
| `Makefile` | Convenience shortcuts |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
```bash
|
||||
# 1. Build & start in background
|
||||
docker compose up --build -d
|
||||
|
||||
# 2. Visit the dashboard
|
||||
open http://localhost:8080
|
||||
|
||||
# 3. Check health
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
Or use the Makefile shortcuts:
|
||||
|
||||
```bash
|
||||
make up # build & start
|
||||
make logs # tail logs
|
||||
make restart # restart container
|
||||
make down # stop container
|
||||
make clean # stop + remove image
|
||||
make health # query /health
|
||||
make status # resource usage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Copy the example environment file and customize:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Available variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Host port to expose the dashboard on |
|
||||
| `TZ` | `UTC` | Container timezone |
|
||||
|
||||
Example — run on port `3000`:
|
||||
|
||||
```bash
|
||||
PORT=3000 docker compose up --build -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Docker Build (multi-stage) │
|
||||
│ │
|
||||
│ Stage 1: node:20-alpine │
|
||||
│ └── npm ci → npm run build │
|
||||
│ └── outputs /app/dist │
|
||||
│ │
|
||||
│ Stage 2: nginx:1.27-alpine │
|
||||
│ └── COPY dist → /usr/share/... │
|
||||
│ └── custom nginx.conf │
|
||||
│ └── runs as nginx (non-root) │
|
||||
│ └── serves on :80 │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security features
|
||||
|
||||
- **Non-root execution** — container runs as `nginx` user
|
||||
- **Security headers** — CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
|
||||
- **CSP whitelists** — only allows CoinGecko + Frankfurter APIs for outbound fetch
|
||||
- **Hidden dot-files** — `.env`, `.git`, etc. blocked from HTTP access
|
||||
- **Resource limits** — max 1 CPU / 256 MB memory
|
||||
- **Log rotation** — 3 × 10 MB files per container
|
||||
|
||||
---
|
||||
|
||||
## 🏭 Production deployment
|
||||
|
||||
### Behind a reverse proxy (Caddy / Traefik / Nginx)
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
services:
|
||||
web:
|
||||
labels:
|
||||
- "traefik.http.routers.parity.rule=Host(`parity.example.com`)"
|
||||
- "traefik.http.routers.parity.tls=true"
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Generate a deployment spec:
|
||||
|
||||
```bash
|
||||
docker compose convert --format kubernetes
|
||||
# or use kompose:
|
||||
kompose convert -f docker-compose.yml
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
```bash
|
||||
# Build & tag for registry
|
||||
docker build -t registry.example.com/tron-parity-ladder:${GIT_SHA} .
|
||||
docker push registry.example.com/tron-parity-ladder:${GIT_SHA}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🩺 Health & monitoring
|
||||
|
||||
```bash
|
||||
# Health endpoint
|
||||
curl http://localhost:8080/health # → "OK"
|
||||
|
||||
# Container health status
|
||||
docker inspect --format='{{.State.Health.Status}}' tron-parity-ladder
|
||||
|
||||
# Live resource stats
|
||||
docker stats tron-parity-ladder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| Port already in use | Change `PORT` in `.env` |
|
||||
| SPA routes 404 | Verify `try_files $uri /index.html` in `nginx.conf` |
|
||||
| API calls blocked | Check CSP header in `docker/nginx.conf` — add origins to `connect-src` |
|
||||
| Build slow | Delete stale layers: `docker system prune -a` |
|
||||
| Container unhealthy | `docker compose logs web` — check nginx error log |
|
||||
|
||||
---
|
||||
|
||||
## 📡 Live Data Architecture
|
||||
|
||||
The SPA pulls from three public APIs with a cascading fallback:
|
||||
|
||||
| Priority | Source | Endpoint | Used for |
|
||||
|----------|--------|----------|----------|
|
||||
| **1 (primary)** | **TronScan** | `apilist.tronscan.org/api/token_trc20` | On-chain TRON token prices, supplies, market caps |
|
||||
| 2 (fallback) | CoinGecko | `api.coingecko.com/v3/coins/markets` | Cross-exchange aggregated prices when TronScan is rate-limited |
|
||||
| 3 (forex) | Frankfurter | `api.frankfurter.dev/v1/latest` | ECB-sourced fiat exchange rates (USD base) |
|
||||
|
||||
Every successful fetch is **recorded to the browser's IndexedDB** (`tron-parity-history` database), enabling time-series and synthetic-pair charts (e.g. BTT/JST, APENFT/WIN) locally on each user's device.
|
||||
|
||||
The header badge reports which source actually supplied the data:
|
||||
- 🟢 **TRONSCAN** — full on-chain data
|
||||
- 🔵 **COINGECKO** — TronScan offline, fallback active
|
||||
- 🟡 **MIXED** — partial TronScan, partial CoinGecko fill
|
||||
- ⚫ **BASELINE** — both APIs unreachable, using bundled constants
|
||||
|
||||
### Path to a persistent backend
|
||||
|
||||
IndexedDB is **per-browser** — data doesn't sync across users or survive a cache clear. For a shared, server-side history that can power real charts, add a companion service:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌───────────┐
|
||||
│ Node Worker │────▶│ Postgres + REST │◀────│ SPA │
|
||||
│ (cron 60s) │ │ /api/history │ │ (browser)│
|
||||
│ → TronScan │ │ /api/synthetic │ └───────────┘
|
||||
└─────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
This worker lives in the same `docker-compose.yml` — swap the SPA's `dataService.ts` to point at `/api/*` instead of the public endpoints, and IndexedDB becomes a client-side cache rather than the source of truth.
|
||||
|
||||
## 📊 Image size
|
||||
|
||||
| Stage | Size |
|
||||
|-------|------|
|
||||
| `node:20-alpine` (builder) | ~200 MB (discarded) |
|
||||
| `nginx:1.27-alpine` (final) | **~25 MB** ✅ |
|
||||
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@ -0,0 +1,46 @@
|
||||
# ============================================
|
||||
# Stage 1: Build the static bundle with Vite
|
||||
# ============================================
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests first to leverage Docker layer cache
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# Copy the rest of the source
|
||||
COPY . .
|
||||
|
||||
# Build the production bundle (outputs to /app/dist)
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Serve with Nginx (production-grade)
|
||||
# ============================================
|
||||
FROM nginx:1.27-alpine AS production
|
||||
|
||||
# Remove default nginx config and static assets
|
||||
RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/*
|
||||
|
||||
# Install custom nginx config
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built bundle from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Security: run as non-root
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chown -R nginx:nginx /var/cache/nginx && \
|
||||
chown -R nginx:nginx /var/log/nginx && \
|
||||
chown -R nginx:nginx /etc/nginx/conf.d && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R nginx:nginx /var/run/nginx.pid
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:80/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
47
Makefile
Normal file
47
Makefile
Normal file
@ -0,0 +1,47 @@
|
||||
# ============================================
|
||||
# TRON Parity Ladder — Docker Makefile
|
||||
# ============================================
|
||||
# Quick shortcuts for Docker-based operations
|
||||
# ============================================
|
||||
|
||||
IMAGE_NAME := tron-parity-ladder
|
||||
IMAGE_TAG := latest
|
||||
|
||||
.PHONY: help build up down logs restart clean shell health
|
||||
|
||||
help: ## Show this help
|
||||
@echo "TRON Parity Ladder — Docker Commands"
|
||||
@echo ""
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build the Docker image (multi-stage)
|
||||
docker compose build --no-cache
|
||||
|
||||
up: ## Build (if needed) and start the container in background
|
||||
docker compose up --build -d
|
||||
@echo ""
|
||||
@echo "✅ Dashboard running at: http://localhost:$${PORT:-8080}"
|
||||
@echo " Health check: http://localhost:$${PORT:-8080}/health"
|
||||
|
||||
down: ## Stop and remove the container
|
||||
docker compose down
|
||||
|
||||
logs: ## Tail container logs (Ctrl+C to stop)
|
||||
docker compose logs -f
|
||||
|
||||
restart: down up ## Restart the container
|
||||
|
||||
clean: down ## Stop container and remove image + volumes
|
||||
docker rmi $(IMAGE_NAME):$(IMAGE_TAG) 2>/dev/null || true
|
||||
docker system prune -f
|
||||
|
||||
shell: ## Open a shell inside the running container
|
||||
docker compose exec web sh
|
||||
|
||||
health: ## Query the health endpoint
|
||||
@curl -s http://localhost:$${PORT:-8080}/health && echo
|
||||
|
||||
status: ## Show container status and resource usage
|
||||
docker compose ps
|
||||
@echo ""
|
||||
docker stats --no-stream $(IMAGE_NAME) 2>/dev/null || true
|
||||
17
deploy-tron-degelas.sh
Executable file
17
deploy-tron-degelas.sh
Executable file
@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROXY_DIR="/root/fullstack_degelas"
|
||||
|
||||
echo "Building and starting TRON stack..."
|
||||
cd "$APP_DIR"
|
||||
docker compose up --build -d
|
||||
|
||||
echo "Reloading nginx reverse proxy..."
|
||||
cd "$PROXY_DIR"
|
||||
docker compose exec nginx nginx -t
|
||||
docker compose exec nginx nginx -s reload
|
||||
|
||||
echo "Done. Verify with:"
|
||||
echo " curl -I https://tron.degelas.be"
|
||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@ -0,0 +1,56 @@
|
||||
# ============================================
|
||||
# TRON Parity Ladder — Production Docker Compose
|
||||
# ============================================
|
||||
# Usage:
|
||||
# docker compose up --build -d
|
||||
# docker compose logs -f
|
||||
# docker compose down
|
||||
# ============================================
|
||||
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
# ------------------------------------------
|
||||
# Web — Serves the TRON Parity Ladder SPA
|
||||
# ------------------------------------------
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
image: tron-parity-ladder:latest
|
||||
container_name: tron-parity-ladder
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=UTC
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- parity-net
|
||||
- fullstack_degelas_proxy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 256M
|
||||
reservations:
|
||||
cpus: "0.25"
|
||||
memory: 64M
|
||||
|
||||
networks:
|
||||
parity-net:
|
||||
driver: bridge
|
||||
fullstack_degelas_proxy:
|
||||
external: true
|
||||
73
docker/nginx.conf
Normal file
73
docker/nginx.conf
Normal file
@ -0,0 +1,73 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# === Security headers ===
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; connect-src 'self' https://apilist.tronscan.org https://api.coingecko.com https://api.frankfurter.dev;" always;
|
||||
|
||||
# === Compression ===
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml;
|
||||
|
||||
# === Static asset caching (1 year — hashed filenames) ===
|
||||
location ~* \.(?:css|js)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# === Media & font caching (1 week) ===
|
||||
location ~* \.(?:png|jpg|jpeg|gif|ico|svg|webp|woff2?|ttf|otf)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# === SEO files (1 hour) ===
|
||||
location ~* ^/(?:robots\.txt|sitemap\.xml|manifest\.json)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# === SPA fallback — serve index.html for any unmatched route ===
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# === Health endpoint ===
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# === Block dot-files (except .well-known) ===
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
247
index.html
Normal file
247
index.html
Normal file
@ -0,0 +1,247 @@
|
||||
<!doctype html>
|
||||
<html lang="en" prefix="og: https://ogp.me/ns#">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard</title>
|
||||
<meta name="title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard" />
|
||||
<meta name="description" content="Track how many X until TRON ecosystem tokens (TRX, JST, SUN, BTT, WIN, APENFT) reach price parity with USD, EUR, GBP, KWD, and 27 global currencies. Live forex parity ladder, market-cap flip targets, sortable comparison matrices. USD = 1 base anchor." />
|
||||
<meta name="keywords" content="TRON, TRX, price parity, market cap flip, crypto dashboard, TRON ecosystem, JST, SUN, BTT, WINkLink, APENFT, USDD, forex parity, crypto ratios, flip ratio, parity ladder, cryptocurrency, DeFi, blockchain, exchange rates, USD parity, token comparison, market cap ranking, crypto tools, TRON DeFi, price targets, KWD, BHD, GBP, CHF, EUR, SGD, CAD, AUD, NZD, JPY, KRW, IDR, INR, THB, TRY, MXN, ZAR, BRL, AED, CNY, HKD, NOK, SEK, DKK, Kuwaiti Dinar, forex ladder, currency strength" />
|
||||
<meta name="author" content="TRON Parity Ladder" />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
|
||||
<meta name="googlebot" content="index, follow" />
|
||||
<meta name="theme-color" content="#FF060A" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="application-name" content="TRON Parity Ladder" />
|
||||
<meta name="apple-mobile-web-app-title" content="TRON Parity" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://tron-parity-ladder.com/" />
|
||||
|
||||
<!-- Favicon & Manifest -->
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/favicon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://tron-parity-ladder.com/" />
|
||||
<meta property="og:title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratios" />
|
||||
<meta property="og:description" content="Calculate how many X until TRX, JST, SUN, BTT, WIN, and APENFT reach $1, €1, or flip each other by market cap. Live forex ratios, sortable ladder, comparison matrix. The TRON ecosystem research framework." />
|
||||
<meta property="og:image" content="https://tron-parity-ladder.com/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="TRON Parity Ladder Dashboard — Price Parity & Market Cap Flip Ratios" />
|
||||
<meta property="og:site_name" content="TRON Parity Ladder" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://tron-parity-ladder.com/" />
|
||||
<meta name="twitter:title" content="TRON Parity Ladder — Price Parity & Market Cap Flip Ratios" />
|
||||
<meta name="twitter:description" content="How many X until TRX hits $1? Track TRON ecosystem price parity goals, forex ratios, and market-cap flip targets. Live data, sortable ladders, interactive matrix." />
|
||||
<meta name="twitter:image" content="https://tron-parity-ladder.com/og-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="TRON Parity Ladder Dashboard" />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "TRON Parity Ladder",
|
||||
"alternateName": "TRON Flip Ratio Framework",
|
||||
"url": "https://tron-parity-ladder.com/",
|
||||
"description": "Track how many X until TRON ecosystem tokens reach price parity with USD and other fiat currencies, or flip each other by market cap. Live forex ratios and sortable comparison tools.",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"operatingSystem": "Web",
|
||||
"browserRequirements": "Requires JavaScript",
|
||||
"softwareVersion": "1.0",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "TRON Parity Ladder"
|
||||
},
|
||||
"screenshot": "https://tron-parity-ladder.com/og-image.jpg",
|
||||
"featureList": [
|
||||
"Price parity ratio calculator",
|
||||
"Market-cap flip targets",
|
||||
"Live forex parity ladder",
|
||||
"Token comparison matrix",
|
||||
"TRON ecosystem tracker",
|
||||
"Real-time data refresh"
|
||||
],
|
||||
"keywords": "TRON, TRX, price parity, market cap, flip ratio, crypto dashboard, forex, DeFi"
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- FAQ Structured Data for rich snippets -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How many X does TRX need to reach $1?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "The TRON Parity Ladder calculates the live X multiplier by dividing the target price ($1.00) by TRX's current USD price. This ratio updates in real-time as TRX price changes."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What is price parity vs market-cap flip?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Price parity measures how many X until 1 token equals a target price (e.g. TRX to $1). Market-cap flip measures how many X until one project's total valuation surpasses another (e.g. JST flipping SUN by market cap). These are fundamentally different metrics."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Which TRON tokens are tracked?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "The dashboard tracks TRX, JST (JUST), SUN (SUN.io), BTT (BitTorrent), WIN (WINkLink), NFT (APENFT), and USDD. Stablecoins like USDD are excluded from market-cap flip calculations."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What forex currencies are included?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "The forex parity ladder tracks 11 currencies: USD (base anchor), EUR, GBP, AUD, NZD, CAD, CHF, JPY, CNY, SGD, and HKD. All are ranked by strength against USD = 1."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "How is the parity X ratio calculated?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "For price parity: X = target price in USD ÷ token price in USD. For market-cap flips: X = target market cap ÷ base market cap. All values are normalized to USD first."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- FinancialProduct Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Dataset",
|
||||
"name": "TRON Ecosystem Parity Ratios",
|
||||
"description": "Live price parity ratios and market-cap flip multipliers for TRON ecosystem tokens including TRX, JST, SUN, BTT, WIN, and APENFT against USD, EUR, GBP, and other major currencies.",
|
||||
"url": "https://tron-parity-ladder.com/",
|
||||
"keywords": ["TRON", "TRX", "cryptocurrency", "price parity", "market cap", "forex", "exchange rates"],
|
||||
"license": "https://creativecommons.org/licenses/by/4.0/",
|
||||
"creator": {
|
||||
"@type": "Organization",
|
||||
"name": "TRON Parity Ladder"
|
||||
},
|
||||
"temporalCoverage": "2026/..",
|
||||
"variableMeasured": [
|
||||
{
|
||||
"@type": "PropertyValue",
|
||||
"name": "Price Parity Ratio",
|
||||
"description": "X multiplier to reach target price"
|
||||
},
|
||||
{
|
||||
"@type": "PropertyValue",
|
||||
"name": "Market Cap Flip Ratio",
|
||||
"description": "X multiplier to surpass target market capitalization"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Breadcrumb Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "TRON Parity Ladder",
|
||||
"item": "https://tron-parity-ladder.com/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "Price Parity",
|
||||
"item": "https://tron-parity-ladder.com/#price-parity"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "Market-Cap Flips",
|
||||
"item": "https://tron-parity-ladder.com/#flips"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Preconnect & DNS Prefetch for performance -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://api.coingecko.com" />
|
||||
<link rel="dns-prefetch" href="https://api.frankfurter.dev" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Fragment+Mono&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Umami analytics -->
|
||||
<script
|
||||
defer
|
||||
src="https://analytics.degelas.be/script.js"
|
||||
data-website-id="4e6a3230-e474-45cc-a07f-2e890056bfb4"
|
||||
data-domains="tron.degelas.be"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Noscript fallback for SEO crawlers -->
|
||||
<noscript>
|
||||
<div style="padding:40px;max-width:800px;margin:0 auto;font-family:system-ui,sans-serif;color:#ededed;background:#0a0a0a;min-height:100vh">
|
||||
<h1>TRON Parity Ladder — Price Parity & Market Cap Flip Ratio Dashboard</h1>
|
||||
<p>Track how many X until TRON ecosystem tokens reach price parity with USD, EUR, GBP, and other global currencies. Compare market-cap flip targets across TRX, JST, SUN, BTT, WIN, and APENFT.</p>
|
||||
<h2>What is Price Parity?</h2>
|
||||
<p>Price parity calculates how many X (multiplier) a token needs to reach a target price. Formula: X = target price ÷ current token price. For example, if TRX is $0.374, it needs 2.67× to reach $1.00.</p>
|
||||
<h2>What is a Market-Cap Flip?</h2>
|
||||
<p>A market-cap flip occurs when one project's total valuation surpasses another. Formula: X = target market cap ÷ base market cap. This is different from price parity because it accounts for circulating supply.</p>
|
||||
<h2>Tracked TRON Ecosystem Tokens</h2>
|
||||
<ul>
|
||||
<li><strong>TRX</strong> — TRON native token</li>
|
||||
<li><strong>JST</strong> — JUST DeFi ecosystem</li>
|
||||
<li><strong>SUN</strong> — SUN.io DEX & DeFi</li>
|
||||
<li><strong>BTT</strong> — BitTorrent ecosystem</li>
|
||||
<li><strong>WIN</strong> — WINkLink oracle infrastructure</li>
|
||||
<li><strong>NFT</strong> — APENFT NFT ecosystem</li>
|
||||
<li><strong>USDD</strong> — TRON-native stablecoin</li>
|
||||
</ul>
|
||||
<h2>Forex Parity Ladder</h2>
|
||||
<p>Compare 27 global currencies ranked by strength against USD = 1. Strongest: KWD (Kuwaiti Dinar $3.26), BHD (Bahraini Dinar $2.65), OMR (Omani Rial $2.60), GBP, CHF, EUR. Weakest: KRW, IDR, JPY.</p>
|
||||
<h2>Key Formulas</h2>
|
||||
<ul>
|
||||
<li>Price Parity: X = Price(target in USD) ÷ Price(base in USD)</li>
|
||||
<li>Market-Cap Flip: X = MarketCap(target) ÷ MarketCap(base)</li>
|
||||
<li>Progress to Target: 1/X × 100%</li>
|
||||
</ul>
|
||||
<p>This dashboard uses USD as the base reference unit. All fiat and crypto assets are normalized into USD. Prices and market caps are live estimates from third-party data providers.</p>
|
||||
<p>Please enable JavaScript to use the interactive dashboard.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2579
package-lock.json
generated
Normal file
2579
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "react-vite-tailwind",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "2.1.1",
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"tailwind-merge": "3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@types/node": "22.19.17",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
21
public/manifest.json
Normal file
21
public/manifest.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "TRON Parity Ladder — Price Parity & Market Cap Flip Dashboard",
|
||||
"short_name": "TRON Parity",
|
||||
"description": "Track how many X until TRON tokens reach price parity or flip each other by market cap. Live forex ratios, sortable ladders, and interactive comparison tools.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0A0A0A",
|
||||
"theme_color": "#FF060A",
|
||||
"orientation": "any",
|
||||
"categories": ["finance", "business", "utilities"],
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
31
public/robots.txt
Normal file
31
public/robots.txt
Normal file
@ -0,0 +1,31 @@
|
||||
# TRON Parity Ladder - Robots.txt
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemap location
|
||||
Sitemap: https://tron-parity-ladder.com/sitemap.xml
|
||||
|
||||
# Crawl-delay (be respectful)
|
||||
Crawl-delay: 1
|
||||
|
||||
# Allow all search engines
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Slurp
|
||||
Allow: /
|
||||
|
||||
User-agent: DuckDuckBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Baiduspider
|
||||
Allow: /
|
||||
|
||||
User-agent: YandexBot
|
||||
Allow: /
|
||||
|
||||
# Block unnecessary paths
|
||||
Disallow: /assets/
|
||||
39
public/sitemap.xml
Normal file
39
public/sitemap.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
|
||||
<!-- Homepage / Overview -->
|
||||
<url>
|
||||
<loc>https://tron-parity-ladder.com/</loc>
|
||||
<lastmod>2026-05-27</lastmod>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
|
||||
<!-- Price Parity View -->
|
||||
<url>
|
||||
<loc>https://tron-parity-ladder.com/#price-parity</loc>
|
||||
<lastmod>2026-05-27</lastmod>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
|
||||
<!-- Comparison Matrix -->
|
||||
<url>
|
||||
<loc>https://tron-parity-ladder.com/#matrix</loc>
|
||||
<lastmod>2026-05-27</lastmod>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
|
||||
<!-- Market-Cap Flips -->
|
||||
<url>
|
||||
<loc>https://tron-parity-ladder.com/#flips</loc>
|
||||
<lastmod>2026-05-27</lastmod>
|
||||
<changefreq>hourly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
|
||||
</urlset>
|
||||
1920
src/App.tsx
Normal file
1920
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
242
src/components/SyntheticChart.tsx
Normal file
242
src/components/SyntheticChart.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createChart, IChartApi, ISeriesApi, LineStyle, AreaSeries } from "lightweight-charts";
|
||||
import {
|
||||
fetchBinanceKlines,
|
||||
buildSyntheticPair,
|
||||
buildUsdSeries,
|
||||
BINANCE_SUPPORTED,
|
||||
type OHLCV,
|
||||
} from "../services/binanceService";
|
||||
import { storeKlines, getKlines, hasRecentKlines } from "../services/ohlcvStore";
|
||||
|
||||
interface Props {
|
||||
base: string;
|
||||
quote: string;
|
||||
mode: "synthetic" | "usd";
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export default function SyntheticChart({ base, quote, mode, height = 420 }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<"Area"> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dataInfo, setDataInfo] = useState<{ points: number; from: string; to: string } | null>(null);
|
||||
|
||||
const loadAndRender = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1) Ensure we have klines for both symbols
|
||||
const symbols = mode === "synthetic" ? [base, quote] : [base];
|
||||
const ohlcvMap: Record<string, OHLCV[]> = {};
|
||||
|
||||
for (const sym of symbols) {
|
||||
const lower = sym.toLowerCase();
|
||||
let data: OHLCV[] = [];
|
||||
|
||||
// Always try to fetch fresh data first for Binance-supported tokens
|
||||
if (BINANCE_SUPPORTED.includes(lower)) {
|
||||
try {
|
||||
// Check if we have recent cached data
|
||||
const cached = await getKlines(lower);
|
||||
const hasRecent = cached.length > 0 && await hasRecentKlines(lower, 6);
|
||||
|
||||
if (!hasRecent) {
|
||||
// Fetch fresh from Binance
|
||||
const fresh = await fetchBinanceKlines(lower, 365);
|
||||
if (fresh.length > 0) {
|
||||
await storeKlines(lower, fresh);
|
||||
data = fresh;
|
||||
} else {
|
||||
data = cached;
|
||||
}
|
||||
} else {
|
||||
data = cached;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fetch failed, use cached if available
|
||||
data = await getKlines(lower);
|
||||
}
|
||||
} else {
|
||||
// Not supported by Binance, use cached only
|
||||
data = await getKlines(lower);
|
||||
}
|
||||
|
||||
ohlcvMap[lower] = data;
|
||||
}
|
||||
|
||||
// 2) Build series
|
||||
let seriesData: { time: number; value: number }[] = [];
|
||||
if (mode === "synthetic") {
|
||||
const baseData = ohlcvMap[base.toLowerCase()] ?? [];
|
||||
const quoteData = ohlcvMap[quote.toLowerCase()] ?? [];
|
||||
if (baseData.length === 0 || quoteData.length === 0) {
|
||||
setError(`Insufficient data for ${base}/${quote}. Binance may not support one of these tokens.`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const synth = buildSyntheticPair(baseData, quoteData, base, quote);
|
||||
seriesData = synth.data;
|
||||
} else {
|
||||
const usdData = ohlcvMap[base.toLowerCase()] ?? [];
|
||||
if (usdData.length === 0) {
|
||||
setError(`No USD price data for ${base.toUpperCase()}. Binance may not support this token.`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const usd = buildUsdSeries(usdData, base);
|
||||
seriesData = usd.data;
|
||||
}
|
||||
|
||||
if (seriesData.length === 0) {
|
||||
setError("No data points to display. Please try another token pair.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Render chart
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Destroy previous chart
|
||||
if (chartRef.current) {
|
||||
chartRef.current.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
}
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
layout: {
|
||||
background: { color: "#0A0A0A" },
|
||||
textColor: "#888",
|
||||
fontFamily: "'Fragment Mono', monospace",
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: "#141414", style: LineStyle.Solid },
|
||||
horzLines: { color: "#141414", style: LineStyle.Solid },
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1,
|
||||
vertLine: { color: "#FF060A", width: 1, style: LineStyle.Dashed, labelBackgroundColor: "#FF060A" },
|
||||
horzLine: { color: "#FF060A", width: 1, style: LineStyle.Dashed, labelBackgroundColor: "#FF060A" },
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: "#1A1A1A",
|
||||
scaleMargins: { top: 0.1, bottom: 0.1 },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: "#1A1A1A",
|
||||
timeVisible: false,
|
||||
secondsVisible: false,
|
||||
},
|
||||
handleScroll: { vertTouchDrag: false },
|
||||
handleScale: { axisPressedMouseMove: true },
|
||||
autoSize: true,
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
|
||||
const series = chart.addSeries(AreaSeries, {
|
||||
topColor: "rgba(255, 6, 10, 0.3)",
|
||||
bottomColor: "rgba(255, 6, 10, 0.01)",
|
||||
lineColor: "#FF060A",
|
||||
lineWidth: 2,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: true,
|
||||
priceLineColor: "#FF060A",
|
||||
priceLineWidth: 1,
|
||||
priceLineStyle: LineStyle.Dashed,
|
||||
});
|
||||
|
||||
seriesRef.current = series;
|
||||
|
||||
// Convert timestamps to lightweight-charts format (seconds)
|
||||
const chartData = seriesData.map((d) => ({
|
||||
time: d.time as any,
|
||||
value: d.value,
|
||||
}));
|
||||
|
||||
series.setData(chartData);
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
// Tooltip
|
||||
chart.subscribeCrosshairMove((param) => {
|
||||
if (!param.time || !param.point) return;
|
||||
const data = param.seriesData.get(series) as any;
|
||||
if (data) {
|
||||
// Tooltip is handled natively by lightweight-charts
|
||||
}
|
||||
});
|
||||
|
||||
setDataInfo({
|
||||
points: seriesData.length,
|
||||
from: new Date(seriesData[0].time * 1000).toLocaleDateString(),
|
||||
to: new Date(seriesData[seriesData.length - 1].time * 1000).toLocaleDateString(),
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Chart load failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [base, quote, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAndRender();
|
||||
return () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.remove();
|
||||
chartRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadAndRender]);
|
||||
|
||||
return (
|
||||
<div className="rounded-[20px] border border-[#1A1A1A] bg-[#0F0F0F] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-[20px] h-[52px] flex items-center justify-between border-b border-[#1A1A1A]">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<div className="h-[8px] w-[8px] rounded-full bg-[#FF060A] animate-pulse" />
|
||||
<span className="mono text-[13px] font-[550] tracking-[-0.01em] text-white">
|
||||
{mode === "synthetic" ? `${base.toUpperCase()}/${quote.toUpperCase()}` : `${base.toUpperCase()}/USD`}
|
||||
</span>
|
||||
<span className="mono text-[10px] px-[7px] h-[18px] rounded-[6px] bg-[#1A1A1A] text-[#888] uppercase tracking-wide flex items-center">
|
||||
1D
|
||||
</span>
|
||||
</div>
|
||||
{dataInfo && (
|
||||
<span className="mono text-[10px] tracking-wide text-[#666]">
|
||||
{dataInfo.points} pts • {dataInfo.from} → {dataInfo.to}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart area */}
|
||||
<div className="relative" style={{ height }}>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#0A0A0A]/80 z-10">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<div className="h-[16px] w-[16px] rounded-full border-2 border-[#FF060A] border-t-transparent animate-spin" />
|
||||
<span className="mono text-[12px] tracking-wide text-[#999]">Loading Binance 1D…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#0A0A0A]/80 z-10">
|
||||
<div className="text-center px-[20px]">
|
||||
<div className="text-[13px] text-[#FF6B70] mb-[6px]">{error}</div>
|
||||
<button
|
||||
onClick={loadAndRender}
|
||||
className="px-[12px] h-[28px] rounded-[8px] bg-[#1A1A1A] hover:bg-[#222] border border-[#222] text-[#AAA] hover:text-white text-[11px] font-[500] transition-all"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/index.css
Normal file
1
src/index.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
147
src/services/binanceService.ts
Normal file
147
src/services/binanceService.ts
Normal file
@ -0,0 +1,147 @@
|
||||
// ============================================================
|
||||
// Binance OHLCV Data Service
|
||||
// ============================================================
|
||||
// Fetches daily (1d) candlestick data from Binance public API.
|
||||
// No API key required. Data is stored in IndexedDB for
|
||||
// synthetic-pair chart rendering (e.g. BTT/JST, APENFT/WIN).
|
||||
//
|
||||
// Endpoint: https://api.binance.com/api/v3/klines
|
||||
// Format: [timestamp, open, high, low, close, volume, ...]
|
||||
//
|
||||
// For tokens not on Binance, falls back to stored snapshots
|
||||
// from the main dataService history pipeline.
|
||||
// ============================================================
|
||||
|
||||
export interface OHLCV {
|
||||
time: number; // unix timestamp (seconds)
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface SyntheticSeries {
|
||||
base: string;
|
||||
quote: string;
|
||||
data: { time: number; value: number }[];
|
||||
}
|
||||
|
||||
// Binance symbol mapping for TRON ecosystem tokens
|
||||
const BINANCE_SYMBOLS: Record<string, string> = {
|
||||
trx: "TRXUSDT",
|
||||
jst: "JSTUSDT",
|
||||
sun: "SUNUSDT",
|
||||
btt: "BTTUSDT",
|
||||
win: "WINUSDT",
|
||||
nft: "NFTUSDT", // may not exist — handled gracefully
|
||||
usdd: "USDDUSDT",
|
||||
};
|
||||
|
||||
// Tokens confirmed on Binance (subset that actually trades)
|
||||
export const BINANCE_SUPPORTED = ["trx", "jst", "sun", "btt", "win", "usdd"];
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Fetch 1D klines from Binance
|
||||
// ----------------------------------------------------------------
|
||||
export async function fetchBinanceKlines(
|
||||
symbol: string,
|
||||
limit = 365
|
||||
): Promise<OHLCV[]> {
|
||||
const binanceSymbol = BINANCE_SYMBOLS[symbol.toLowerCase()];
|
||||
if (!binanceSymbol) {
|
||||
throw new Error(`No Binance symbol mapping for ${symbol}`);
|
||||
}
|
||||
|
||||
const url = `https://api.binance.com/api/v3/klines?symbol=${binanceSymbol}&interval=1d&limit=${limit}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Binance HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const raw = await res.json();
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error("Invalid Binance response");
|
||||
}
|
||||
|
||||
return raw.map((c: any[]) => ({
|
||||
time: Math.floor(c[0] / 1000), // ms → s
|
||||
open: parseFloat(c[1]),
|
||||
high: parseFloat(c[2]),
|
||||
low: parseFloat(c[3]),
|
||||
close: parseFloat(c[4]),
|
||||
volume: parseFloat(c[5]),
|
||||
}));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Fetch multiple tokens in parallel
|
||||
// ----------------------------------------------------------------
|
||||
export async function fetchMultipleKlines(
|
||||
symbols: string[],
|
||||
limit = 365
|
||||
): Promise<Record<string, OHLCV[]>> {
|
||||
const results: Record<string, OHLCV[]> = {};
|
||||
|
||||
await Promise.all(
|
||||
symbols.map(async (sym) => {
|
||||
try {
|
||||
const data = await fetchBinanceKlines(sym, limit);
|
||||
results[sym.toLowerCase()] = data;
|
||||
} catch (err) {
|
||||
console.warn(`Binance klines failed for ${sym}:`, err);
|
||||
results[sym.toLowerCase()] = [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Build synthetic pair series from two OHLCV sets
|
||||
// Example: base="btt", quote="jst" → BTT/JST ratio over time
|
||||
// ----------------------------------------------------------------
|
||||
export function buildSyntheticPair(
|
||||
baseData: OHLCV[],
|
||||
quoteData: OHLCV[],
|
||||
baseSymbol: string,
|
||||
quoteSymbol: string
|
||||
): SyntheticSeries {
|
||||
// Index quote data by timestamp for fast lookup
|
||||
const quoteMap = new Map<number, OHLCV>();
|
||||
for (const q of quoteData) {
|
||||
quoteMap.set(q.time, q);
|
||||
}
|
||||
|
||||
const synthetic: { time: number; value: number }[] = [];
|
||||
|
||||
for (const b of baseData) {
|
||||
const q = quoteMap.get(b.time);
|
||||
if (!q || q.close === 0) continue;
|
||||
synthetic.push({
|
||||
time: b.time,
|
||||
value: b.close / q.close,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
base: baseSymbol.toUpperCase(),
|
||||
quote: quoteSymbol.toUpperCase(),
|
||||
data: synthetic,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Build USD-denominated price series (just the close prices)
|
||||
// ----------------------------------------------------------------
|
||||
export function buildUsdSeries(data: OHLCV[], symbol: string) {
|
||||
return {
|
||||
symbol: symbol.toUpperCase(),
|
||||
data: data.map((d) => ({ time: d.time, value: d.close })),
|
||||
};
|
||||
}
|
||||
315
src/services/dataService.ts
Normal file
315
src/services/dataService.ts
Normal file
@ -0,0 +1,315 @@
|
||||
// ============================================================
|
||||
// TRON Parity Ladder — Central Data Service
|
||||
// ============================================================
|
||||
// Abstraction layer for fetching and caching TRON market data.
|
||||
// Designed to swap between multiple data sources (TronScan,
|
||||
// CoinGecko, Frankfurter) and persist snapshots to IndexedDB
|
||||
// for future time-series / synthetic chart features.
|
||||
//
|
||||
// Architecture path:
|
||||
// Phase 1 (now) → Browser fetch + IndexedDB history
|
||||
// Phase 2 (next) → Node worker + Postgres + REST API
|
||||
// ============================================================
|
||||
|
||||
import { recordPriceSnapshot } from "./historyStore";
|
||||
|
||||
export interface RawTokenData {
|
||||
id: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
price_usd: number;
|
||||
market_cap_usd: number;
|
||||
circulating_supply: number;
|
||||
change_24h: number;
|
||||
source: "tronscan" | "coingecko" | "fallback";
|
||||
last_updated: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface RawForexData {
|
||||
base: string;
|
||||
date: string;
|
||||
rates: Record<string, number>;
|
||||
source: "frankfurter" | "fallback";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// TRONSCAN ENDPOINTS (Primary — on-chain TRON data)
|
||||
// ----------------------------------------------------------------
|
||||
// Docs: https://docs.tronscan.org/api-endpoints/tokens
|
||||
// Base: https://apilist.tronscan.org
|
||||
//
|
||||
// Endpoints we use:
|
||||
// /api/token/trc20?contract=<addr> → single TRC20 token
|
||||
// /api/token_price?token=trx → TRX price ticker
|
||||
// /api/statistic/latest → ecosystem-wide stats
|
||||
// ----------------------------------------------------------------
|
||||
const TRONSCAN_BASE = "https://apilist.tronscan.org";
|
||||
|
||||
// Canonical TRC20 contract addresses on TRON mainnet
|
||||
const TRC20_CONTRACTS: Record<string, string> = {
|
||||
jst: "TCFLL5dx5ZJd5WAwY7DCxAVHbQXqR5YZv1",
|
||||
sun: "TSSMHYeV2uE9qYH95DqyoCuEz8E23GfJwP",
|
||||
btt: "TAFjULxiVgT4qWk6UZwjqwZXTSaGaqnVp4",
|
||||
win: "TLa2f6VPqDgRE67v1736s7bJ8Ray5wYjU7",
|
||||
nft: "TFczxzPhnThNSqr5by8tvxsdCFRRz6cPNq",
|
||||
usdd: "TPYmHEhy5n8TCEfYGqW2rRcMJmScJXNdSK",
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Helper: fetch with timeout & retry
|
||||
// ----------------------------------------------------------------
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs = 8000,
|
||||
retries = 2
|
||||
): Promise<Response> {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (attempt === retries) throw err;
|
||||
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// TronScan: Fetch TRX native price
|
||||
// ----------------------------------------------------------------
|
||||
async function fetchTrxPriceTronScan(): Promise<{ price: number; change: number } | null> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${TRONSCAN_BASE}/api/token_price?token=trx`,
|
||||
6000,
|
||||
1
|
||||
);
|
||||
const data = await res.json();
|
||||
if (data?.price) {
|
||||
return {
|
||||
price: parseFloat(data.price),
|
||||
change: parseFloat(data.change ?? "0"),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// TronScan: Fetch TRC20 token info (price, supply, market cap)
|
||||
// ----------------------------------------------------------------
|
||||
async function fetchTrc20FromTronScan(
|
||||
symbol: string,
|
||||
contract: string
|
||||
): Promise<RawTokenData | null> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${TRONSCAN_BASE}/api/token_trc20?start=0&limit=1&contract=${contract}`,
|
||||
6000,
|
||||
1
|
||||
);
|
||||
const data = await res.json();
|
||||
const token = data?.data?.[0];
|
||||
if (!token) return null;
|
||||
|
||||
const price = parseFloat(token.priceInUsd ?? "0");
|
||||
const supply = parseFloat(token.totalSupply ?? token.holderCount ?? "0");
|
||||
// TronScan totalSupply is in raw units; divide by 10^decimals
|
||||
const decimals = parseInt(token.decimals ?? "6", 10);
|
||||
const normalizedSupply = supply / Math.pow(10, decimals);
|
||||
const marketCap = price * normalizedSupply;
|
||||
|
||||
return {
|
||||
id: symbol.toLowerCase(),
|
||||
symbol: symbol.toUpperCase(),
|
||||
name: token.name || symbol.toUpperCase(),
|
||||
price_usd: price,
|
||||
market_cap_usd: marketCap,
|
||||
circulating_supply: normalizedSupply,
|
||||
change_24h: parseFloat(token.gain ?? "0"),
|
||||
source: "tronscan",
|
||||
last_updated: new Date().toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// CoinGecko: Fallback for TRON ecosystem tokens
|
||||
// ----------------------------------------------------------------
|
||||
const COINGECKO_IDS: Record<string, string> = {
|
||||
trx: "tron",
|
||||
jst: "just",
|
||||
sun: "sun-token",
|
||||
btt: "bittorrent-new",
|
||||
win: "wink",
|
||||
nft: "apenft",
|
||||
usdd: "usdd",
|
||||
};
|
||||
|
||||
async function fetchCoinGeckoBatch(symbols: string[]): Promise<RawTokenData[]> {
|
||||
try {
|
||||
const ids = symbols
|
||||
.map((s) => COINGECKO_IDS[s.toLowerCase()])
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
if (!ids) return [];
|
||||
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${ids}&order=market_cap_desc&per_page=20&page=1&sparkline=false&price_change_percentage=24h`,
|
||||
8000,
|
||||
1
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
|
||||
return data.map((t: any) => ({
|
||||
id: Object.keys(COINGECKO_IDS).find((k) => COINGECKO_IDS[k] === t.id) ?? t.id,
|
||||
symbol: (t.symbol ?? "").toUpperCase(),
|
||||
name: t.name,
|
||||
price_usd: t.current_price ?? 0,
|
||||
market_cap_usd: t.market_cap ?? 0,
|
||||
circulating_supply: t.circulating_supply ?? 0,
|
||||
change_24h: t.price_change_percentage_24h ?? 0,
|
||||
source: "coingecko",
|
||||
last_updated: t.last_updated ?? new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Forex: Frankfurter (ECB rates, free, CORS-enabled)
|
||||
// ----------------------------------------------------------------
|
||||
const FIAT_SYMBOLS = [
|
||||
"EUR", "GBP", "CHF", "AUD", "NZD", "CAD", "SGD",
|
||||
"JPY", "CNY", "HKD", "SEK", "NOK", "DKK", "BRL",
|
||||
"ZAR", "MXN", "THB", "TRY", "INR", "PHP", "KRW", "IDR",
|
||||
];
|
||||
|
||||
async function fetchForexRates(): Promise<RawForexData | null> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.frankfurter.dev/v1/latest?base=USD&symbols=${FIAT_SYMBOLS.join(",")}`,
|
||||
6000,
|
||||
1
|
||||
);
|
||||
const data = await res.json();
|
||||
return {
|
||||
base: "USD",
|
||||
date: data.date ?? new Date().toISOString().slice(0, 10),
|
||||
rates: data.rates ?? {},
|
||||
source: "frankfurter",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PUBLIC: Fetch all market data (TronScan primary, CoinGecko fallback)
|
||||
// ----------------------------------------------------------------
|
||||
export interface MarketSnapshot {
|
||||
tokens: RawTokenData[];
|
||||
forex: RawForexData;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
export async function fetchMarketData(): Promise<MarketSnapshot> {
|
||||
const fetched_at = new Date().toISOString();
|
||||
const targetSymbols = ["trx", "jst", "sun", "btt", "win", "nft", "usdd"];
|
||||
|
||||
// 1) Attempt TronScan for TRX
|
||||
const trxFromTronScan = await fetchTrxPriceTronScan();
|
||||
|
||||
// 2) Attempt TronScan for each TRC20
|
||||
const trc20Tasks = Object.entries(TRC20_CONTRACTS).map(([sym, contract]) =>
|
||||
fetchTrc20FromTronScan(sym, contract)
|
||||
);
|
||||
const trc20Results = await Promise.all(trc20Tasks);
|
||||
|
||||
// 3) Fill gaps with CoinGecko batch
|
||||
const tronscanHits = new Map<string, RawTokenData>();
|
||||
if (trxFromTronScan) {
|
||||
tronscanHits.set("trx", {
|
||||
id: "trx",
|
||||
symbol: "TRX",
|
||||
name: "TRON",
|
||||
price_usd: trxFromTronScan.price,
|
||||
market_cap_usd: 0, // filled by CoinGecko if needed
|
||||
circulating_supply: 0,
|
||||
change_24h: trxFromTronScan.change,
|
||||
source: "tronscan",
|
||||
last_updated: fetched_at,
|
||||
});
|
||||
}
|
||||
trc20Results.forEach((r) => {
|
||||
if (r) tronscanHits.set(r.id, r);
|
||||
});
|
||||
|
||||
const missingSymbols = targetSymbols.filter((s) => !tronscanHits.has(s));
|
||||
const coingeckoFill = missingSymbols.length > 0
|
||||
? await fetchCoinGeckoBatch(missingSymbols)
|
||||
: [];
|
||||
|
||||
// 4) Merge: TronScan wins, CoinGecko fills, fallback constant otherwise
|
||||
const tokens: RawTokenData[] = targetSymbols.map((sym) => {
|
||||
const fromTronScan = tronscanHits.get(sym);
|
||||
if (fromTronScan && fromTronScan.price_usd > 0) return fromTronScan;
|
||||
|
||||
const fromCoinGecko = coingeckoFill.find(
|
||||
(t) => t.id.toLowerCase() === sym.toLowerCase()
|
||||
);
|
||||
if (fromCoinGecko && fromCoinGecko.price_usd > 0) return fromCoinGecko;
|
||||
|
||||
// Last-resort fallback with last-known values
|
||||
return {
|
||||
id: sym,
|
||||
symbol: sym.toUpperCase(),
|
||||
name: sym.toUpperCase(),
|
||||
price_usd: 0,
|
||||
market_cap_usd: 0,
|
||||
circulating_supply: 0,
|
||||
change_24h: 0,
|
||||
source: "fallback",
|
||||
last_updated: fetched_at,
|
||||
};
|
||||
});
|
||||
|
||||
// 5) Forex
|
||||
const forex = (await fetchForexRates()) ?? {
|
||||
base: "USD",
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
rates: {},
|
||||
source: "fallback" as const,
|
||||
};
|
||||
|
||||
const snapshot: MarketSnapshot = { tokens, forex, fetched_at };
|
||||
|
||||
// 6) Persist snapshot to IndexedDB for time-series / future charts
|
||||
try {
|
||||
await recordPriceSnapshot(snapshot);
|
||||
} catch (err) {
|
||||
console.warn("History record failed (non-fatal):", err);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PUBLIC: Read historical snapshots from IndexedDB
|
||||
// ----------------------------------------------------------------
|
||||
export { getSnapshots, clearHistory, getSyntheticPairHistory } from "./historyStore";
|
||||
220
src/services/historyStore.ts
Normal file
220
src/services/historyStore.ts
Normal file
@ -0,0 +1,220 @@
|
||||
// ============================================================
|
||||
// TRON Parity Ladder — History Store (IndexedDB)
|
||||
// ============================================================
|
||||
// Persists every price snapshot fetched by the data service into
|
||||
// the browser's IndexedDB. This enables:
|
||||
//
|
||||
// • Offline resilience (data survives page reload)
|
||||
// • Local time-series for charts (per-user device)
|
||||
// • Synthetic pair history (e.g. BTT/JST, APENFT/WIN)
|
||||
//
|
||||
// Storage layout (IDB schema v1):
|
||||
// Database: "tron-parity-history"
|
||||
// Object store: "snapshots" (keyPath: "timestamp")
|
||||
// → each record: { timestamp, tokens: { [id]: price, mc, supply, change, source }, forex }
|
||||
//
|
||||
// Retention: default 30 days (configurable). Auto-pruned on write.
|
||||
// ============================================================
|
||||
|
||||
import type { MarketSnapshot } from "./dataService";
|
||||
|
||||
const DB_NAME = "tron-parity-history";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "snapshots";
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Open / create the IndexedDB connection
|
||||
// ----------------------------------------------------------------
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: "timestamp" });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Shape the snapshot into a compact record
|
||||
// ----------------------------------------------------------------
|
||||
interface HistoryRecord {
|
||||
timestamp: number; // unix ms
|
||||
tokens: Record<string, {
|
||||
price: number;
|
||||
mc: number;
|
||||
supply: number;
|
||||
change: number;
|
||||
src: string;
|
||||
}>;
|
||||
forex: Record<string, number>;
|
||||
}
|
||||
|
||||
function toRecord(snap: MarketSnapshot): HistoryRecord {
|
||||
const tokens: HistoryRecord["tokens"] = {};
|
||||
for (const t of snap.tokens) {
|
||||
tokens[t.id.toLowerCase()] = {
|
||||
price: t.price_usd,
|
||||
mc: t.market_cap_usd,
|
||||
supply: t.circulating_supply,
|
||||
change: t.change_24h,
|
||||
src: t.source,
|
||||
};
|
||||
}
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
tokens,
|
||||
forex: snap.forex.rates,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Write a snapshot (and prune old records)
|
||||
// ----------------------------------------------------------------
|
||||
export async function recordPriceSnapshot(snap: MarketSnapshot): Promise<void> {
|
||||
const db = await openDB();
|
||||
const record = toRecord(snap);
|
||||
const cutoff = Date.now() - RETENTION_MS;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
|
||||
// Write new record
|
||||
store.put(record);
|
||||
|
||||
// Prune records older than retention window
|
||||
const range = IDBKeyRange.upperBound(cutoff);
|
||||
const cursorReq = store.openCursor(range);
|
||||
cursorReq.onsuccess = () => {
|
||||
const cursor = cursorReq.result;
|
||||
if (cursor) {
|
||||
store.delete(cursor.primaryKey);
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Read all snapshots in a time range (default: all within retention)
|
||||
// ----------------------------------------------------------------
|
||||
export interface StoredSnapshot {
|
||||
timestamp: number;
|
||||
tokens: Record<string, {
|
||||
price: number;
|
||||
mc: number;
|
||||
supply: number;
|
||||
change: number;
|
||||
src: string;
|
||||
}>;
|
||||
forex: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getSnapshots(opts?: {
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit?: number;
|
||||
}): Promise<StoredSnapshot[]> {
|
||||
const db = await openDB();
|
||||
const since = opts?.since ?? Date.now() - RETENTION_MS;
|
||||
const until = opts?.until ?? Date.now();
|
||||
const limit = opts?.limit ?? Infinity;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const results: StoredSnapshot[] = [];
|
||||
|
||||
const range = IDBKeyRange.bound(since, until);
|
||||
const req = store.openCursor(range);
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (cursor && results.length < limit) {
|
||||
results.push(cursor.value as StoredSnapshot);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Synthetic pair history: e.g. BTT/JST, APENFT/WIN
|
||||
//
|
||||
// Computes price(base) / price(quote) for each timestamp.
|
||||
// Example: syntheticPairHistory("btt", "jst") returns a series
|
||||
// where each point = "how many BTT equal 1 JST" at that time.
|
||||
// ----------------------------------------------------------------
|
||||
export interface SyntheticPoint {
|
||||
timestamp: number;
|
||||
ratio: number; // price(base) / price(quote)
|
||||
basePrice: number;
|
||||
quotePrice: number;
|
||||
}
|
||||
|
||||
export async function getSyntheticPairHistory(
|
||||
base: string,
|
||||
quote: string,
|
||||
opts?: { since?: number; until?: number }
|
||||
): Promise<SyntheticPoint[]> {
|
||||
const snaps = await getSnapshots(opts);
|
||||
const baseKey = base.toLowerCase();
|
||||
const quoteKey = quote.toLowerCase();
|
||||
|
||||
const points: SyntheticPoint[] = [];
|
||||
for (const s of snaps) {
|
||||
const b = s.tokens[baseKey];
|
||||
const q = s.tokens[quoteKey];
|
||||
if (!b || !q || !q.price || !b.price) continue;
|
||||
points.push({
|
||||
timestamp: s.timestamp,
|
||||
ratio: b.price / q.price,
|
||||
basePrice: b.price,
|
||||
quotePrice: q.price,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Clear all stored snapshots (for debugging / privacy)
|
||||
// ----------------------------------------------------------------
|
||||
export async function clearHistory(): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Diagnostic: storage stats (used by the dev footer)
|
||||
// ----------------------------------------------------------------
|
||||
export async function getStorageStats(): Promise<{
|
||||
recordCount: number;
|
||||
oldestTimestamp: number | null;
|
||||
newestTimestamp: number | null;
|
||||
}> {
|
||||
const snaps = await getSnapshots();
|
||||
if (snaps.length === 0) {
|
||||
return { recordCount: 0, oldestTimestamp: null, newestTimestamp: null };
|
||||
}
|
||||
return {
|
||||
recordCount: snaps.length,
|
||||
oldestTimestamp: snaps[0].timestamp,
|
||||
newestTimestamp: snaps[snaps.length - 1].timestamp,
|
||||
};
|
||||
}
|
||||
176
src/services/ohlcvStore.ts
Normal file
176
src/services/ohlcvStore.ts
Normal file
@ -0,0 +1,176 @@
|
||||
// ============================================================
|
||||
// OHLCV IndexedDB Store
|
||||
// ============================================================
|
||||
// Dedicated storage for daily candlestick data fetched from
|
||||
// Binance. Separate from the main snapshot store so we can
|
||||
// keep years of 1D data without bloating the live-snapshot DB.
|
||||
//
|
||||
// Database: "tron-parity-ohlcv"
|
||||
// Object store: "klines" (keyPath: compound [symbol+time])
|
||||
//
|
||||
// Synthetic pair charts read from here, compute ratios on-the-fly.
|
||||
// ============================================================
|
||||
|
||||
import type { OHLCV } from "./binanceService";
|
||||
|
||||
const DB_NAME = "tron-parity-ohlcv";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "klines";
|
||||
|
||||
interface KlineRecord {
|
||||
id: string; // "trx_1698796800"
|
||||
symbol: string; // "trx"
|
||||
time: number; // unix seconds
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
fetchedAt: string; // ISO
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
||||
store.createIndex("symbol_time", ["symbol", "time"], { unique: true });
|
||||
store.createIndex("symbol", "symbol", { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Write klines for a single symbol (batch insert with transaction)
|
||||
// ----------------------------------------------------------------
|
||||
export async function storeKlines(symbol: string, data: OHLCV[]): Promise<void> {
|
||||
if (data.length === 0) return;
|
||||
const db = await openDB();
|
||||
const lower = symbol.toLowerCase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
for (const d of data) {
|
||||
const record: KlineRecord = {
|
||||
id: `${lower}_${d.time}`,
|
||||
symbol: lower,
|
||||
time: d.time,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
fetchedAt,
|
||||
};
|
||||
store.put(record);
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Read klines for a symbol, optionally filtered by time range
|
||||
// ----------------------------------------------------------------
|
||||
export async function getKlines(
|
||||
symbol: string,
|
||||
opts?: { since?: number; until?: number }
|
||||
): Promise<OHLCV[]> {
|
||||
const db = await openDB();
|
||||
const lower = symbol.toLowerCase();
|
||||
const since = opts?.since ?? 0;
|
||||
const until = opts?.until ?? Infinity;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const idx = store.index("symbol");
|
||||
const results: OHLCV[] = [];
|
||||
|
||||
const req = idx.openCursor(IDBKeyRange.only(lower));
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (cursor) {
|
||||
const r = cursor.value as KlineRecord;
|
||||
if (r.time >= since && r.time <= until) {
|
||||
results.push({
|
||||
time: r.time,
|
||||
open: r.open,
|
||||
high: r.high,
|
||||
low: r.low,
|
||||
close: r.close,
|
||||
volume: r.volume,
|
||||
});
|
||||
}
|
||||
cursor.continue();
|
||||
} else {
|
||||
// Sort ascending by time
|
||||
results.sort((a, b) => a.time - b.time);
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Check if we already have recent data (avoid re-fetching)
|
||||
// ----------------------------------------------------------------
|
||||
export async function hasRecentKlines(symbol: string, maxAgeHours = 6): Promise<boolean> {
|
||||
const db = await openDB();
|
||||
const lower = symbol.toLowerCase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readonly");
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const idx = store.index("symbol");
|
||||
|
||||
const req = idx.openCursor(IDBKeyRange.only(lower), "prev");
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (!cursor) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const r = cursor.value as KlineRecord;
|
||||
const ageHours = (Date.now() - new Date(r.fetchedAt).getTime()) / (1000 * 60 * 60);
|
||||
resolve(ageHours < maxAgeHours);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Get date range of stored data for a symbol
|
||||
// ----------------------------------------------------------------
|
||||
export async function getKlineRange(symbol: string): Promise<{ oldest: number | null; newest: number | null; count: number }> {
|
||||
const all = await getKlines(symbol);
|
||||
if (all.length === 0) return { oldest: null, newest: null, count: 0 };
|
||||
return {
|
||||
oldest: all[0].time,
|
||||
newest: all[all.length - 1].time,
|
||||
count: all.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Clear all OHLCV data (nuclear option)
|
||||
// ----------------------------------------------------------------
|
||||
export async function clearOhlcv(): Promise<void> {
|
||||
const db = await openDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, "readwrite");
|
||||
tx.objectStore(STORE_NAME).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
21
src/umami.ts
Normal file
21
src/umami.ts
Normal file
@ -0,0 +1,21 @@
|
||||
type UmamiPayload = Record<string, string | number | boolean | null | undefined>
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (eventName: string, data?: UmamiPayload) => void
|
||||
identify?: (uniqueId: string, data?: UmamiPayload) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function trackEvent(eventName: string, data?: UmamiPayload) {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!window.umami || typeof window.umami.track !== 'function') return
|
||||
|
||||
try {
|
||||
window.umami.track(eventName, data)
|
||||
} catch {
|
||||
// Analytics must never break the app.
|
||||
}
|
||||
}
|
||||
6
src/utils/cn.ts
Normal file
6
src/utils/cn.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss(), viteSingleFile()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user