refactor: migrate TrumpSignal to TypeScript monorepo and Pages Router #1

Merged
Zayden-Jung merged 1 commits from codex/typescript-monorepo into main 2026-11-03 14:40:35 +00:00
188 changed files with 52461 additions and 2 deletions
+27
View File
@@ -0,0 +1,27 @@
DATABASE_URL=postgresql://trumpsignal:change-me@localhost:5432/trumpsignal
ENVIRONMENT=development
FRONTEND_URL=http://localhost:3001
API_URL=http://localhost:8000
NEXT_PUBLIC_SITE_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws
ENCRYPTION_KEY=
INGEST_API_KEY=
AI_API_KEY=
AI_BASE_URL=https://api.deepseek.com/v1
AI_MODEL=deepseek-v4-pro
AI_LIVE_MODEL=deepseek-v4-flash
ANTHROPIC_API_KEY=
HL_MAINNET=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME=
TELEGRAM_PUBLIC_CHANNEL_ID=
ETHERSCAN_API_KEY=
TWITTERAPI_IO_KEY=
X_ENABLED=false
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_SECRET=
# Set only to known reverse-proxy IPs/CIDRs in the deployment environment.
TRUSTED_PROXY=
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
+3
View File
@@ -7,3 +7,6 @@ dist/
*.tsbuildinfo
*.log
.DS_Store
*.dump
*.sqlite
*.db
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+75 -2
View File
@@ -1,3 +1,76 @@
# TrumpSignal Monorepo
# TrumpSignal monorepo
Independent TypeScript migration of TrumpSignal. Implementation is delivered through a reviewed work branch.
将原有 Next.js 前端与 Python/FastAPI 后端迁移为 TypeScript monorepo。前端使用 **Next.js Pages Router + Tailwind CSS + shadcn/ui**,API、后台任务、共享业务逻辑和数据库代码位于同一个仓库。
## 目录
| 目录 | 职责 |
| -------------------- | ---------------------------------------------------------------------------- |
| `apps/web` | 17 个页面入口;Pages Router、SSR、钱包连接、行情图表、SEO/PWA、同源 API 代理 |
| `apps/api` | Fastify API、钱包签名验证、WebSocket、公开查询与用户操作 |
| `apps/worker` | 单实例后台进程:行情、采集、扫描、仓位监控、Telegram、定时任务 |
| `packages/backend` | 交易、账户、采集、AI、KOL、宏观和通知服务 |
| `packages/core` | 策略、指标、签名、Fernet 兼容与保守回测等纯逻辑 |
| `packages/contracts` | 共享接口类型与浏览器签名消息构造 |
| `packages/database` | Drizzle/PostgreSQL 表结构与 SQL 迁移源码 |
| `packages/ui` | 共享 shadcn/ui Button、Input、Card 和样式工具 |
| `scripts` | 显式执行的迁移、导入、备份、运维与静态检查命令 |
| `tests` | 行为回归测试源码;本次未执行 |
| `migration` | 原始 commit、逐模块对应、接口清单、静态核对结果与交付说明 |
## 本次验证边界
已执行不生成产物的 TypeScript 静态类型检查、源码语法/结构检查与 Git 差异检查。结构核对覆盖原项目 **55 个 API handler、17 个页面、11 张表的 184 个字段**。
按用户要求,**没有启动应用、执行功能测试、编译构建、运行数据库迁移、连接交易账户或部署**。静态检查不证明新旧系统行为完全等价;交易、数据导入、外部 API、WebSocket 和通知仍需另行做运行验收。
## 环境与依赖
使用 Node.js 22 LTS(至少 22.14)或满足依赖要求的后续 LTS,以及 PostgreSQL。原前端的直接依赖和 transitive lock entries 用作 monorepo lockfile 的迁移基础,避免在重构时顺便升级钱包栈。
依赖准备可使用 `npm ci --ignore-scripts`。这是禁止安装脚本的安装方式,不启动项目;本次依赖准备也禁用了脚本。项目 `.npmrc` 保留 `legacy-peer-deps=true`,避免钱包库自动拉入无关的 React Native peer 环境。
复制根目录 `.env.example` 为 `.env`,按需填写服务端环境。前端在自己的目录读取环境文件,复制 `apps/web/.env.example` 到 `apps/web/.env.local`。真实密钥、数据库文件与备份均被 Git 忽略。未配置的外部集成保持关闭;示例使用 Hyperliquid testnet 且关闭 X 发布。
## 仅在以后明确需要运行时执行
下列命令是使用说明,本次没有运行启动、测试、迁移或备份命令。
| 命令 | 用途 |
| ---------------------------------- | ------------------------------------------------ |
| `npm run typecheck` | `tsc --noEmit`,不生成产物 |
| `npm run check:structure` | 静态核对路由、页面和数据库字段 |
| `npm test` | 执行行为回归测试,需要单独安排验证环境 |
| `npm run migrate` | 显式应用 SQL;旧 PostgreSQL 必须已到 Alembic 027 |
| `npm run start:api` | 启动 API,默认 8000 |
| `npm run start:worker` | 启动唯一 worker |
| `npm run dev:web` | 启动前端,端口 3001 |
| `npm run ops -- preflight` | 检查数据库连通性和配置存在性 |
| `npm run ops -- seed-wallets` | 幂等导入原项目的公开钱包种子 |
| `npm run ops -- reanalyze 500` | 默认 dry-run 的重分析工具 |
| `npm run ops -- backtest-batch 50` | 历史行情回测,不下单 |
所有构建、打包和隐含编译的运行验证,都应在用户确认的远程 runner 上执行。本仓库没有添加自动触发构建的 CI 工作流,也没有假定任何现有 runner 已获授权。
## 数据兼容
保留原生产 PostgreSQL 的表名、字段名、日期语义和 `enc:v1`/`enc:v2` Fernet 数据格式。`0001_legacy_027_baseline.sql` 对应旧库 Alembic 027 的最终结构;`0002_runtime_state.sql` 增加签名防重放、进程间事件、价格与运行状态。迁移不会在启动时自动执行。
新运行环境统一使用 PostgreSQL:API/worker 通过数据库通知同步事件,用 advisory lock 串行化钱包和仓位操作。旧 SQLite 提供 `scripts/import-sqlite.ts` 只读导入工具,目标必须是空的、已迁移的 PostgreSQL;新服务不直接运行 SQLite。导入和密钥重加密均未在本次执行。旧库回滚需要保留原数据库备份与原应用版本;没有自动回滚交易的操作。
## 业务规则
- Trump 是唯一自动开仓来源。BTC 底部与资金费率信号保持手动开仓、接管管理或仅提醒;未知来源不自动交易。
- 释放管理不平仓,自动监控与恢复跳过 `released_at` 已设置的仓位。
- 退出使用交易行的冻结参数,保留实际杠杆、分段减仓、Grow、阶梯止损、峰值和独立熔断状态。
- 回测使用保守的同根 K 线执行顺序,忽略手续费和滑点;不等于真实收益。
- 已停用的 Breakout Monitor 保留 API 和显式运维工具,但不重新加入调度;两个历史归档实验保持不启用。
- 日报开关与实时提醒开关独立。Telegram 绑定、静默时段、来源偏好与接管/释放命令保留。
反向代理部署时,仅将已知代理 IP/CIDR 写入 `TRUSTED_PROXY`;未配置时不信任客户端提供的转发 IP。前端代理和 SSR 的后端地址统一使用 `API_URL`。
## 参考与追踪
原仓库与 commit 见 `migration/inventory.json`;55 个 handler 的对应关系见 `migration/routes.json`。源码交付不表示已经合并、部署或做过线上验证。
路由转换参考 [Next.js Pages Router](https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts),共享 UI 参考 [shadcn/ui monorepo](https://ui.shadcn.com/docs/monorepo),交易客户端使用 [Hyperliquid TypeScript SDK](https://github.com/nktkas/hyperliquid)。Fernet 固定测试向量来自 [Fernet specification](https://github.com/fernet/spec/blob/master/verify.json)。
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@trumpsignal/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/main.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trumpsignal/database": "*",
"@trumpsignal/contracts": "*",
"@trumpsignal/core": "*",
"fastify": "^5.3.0",
"@fastify/cors": "^11.0.0",
"@fastify/rate-limit": "^10.2.1",
"drizzle-orm": "^0.44.0",
"@trumpsignal/backend": "*",
"zod": "^3.24.2",
"@fastify/websocket": "^11.0.1"
},
"devDependencies": {
"tsx": "^4.19.3"
}
}
+113
View File
@@ -0,0 +1,113 @@
import Fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import cors from '@fastify/cors'
import websocket from '@fastify/websocket'
import { ZodError } from 'zod'
import type { Context } from '@trumpsignal/backend/context'
import type { Services } from '@trumpsignal/backend/services'
import { registerPosts } from './routes/posts'
import { registerMacro } from './routes/macro'
import { registerAccounts } from './routes/accounts'
import { registerPositions } from './routes/positions'
import { registerSignals } from './routes/signals'
import { registerKol } from './routes/kol'
import { registerTelegram } from './routes/telegram'
import { registerDev } from './routes/dev'
export async function createApp(c: Context, s: Services) {
const app = Fastify({
logger: {
redact: [
'req.headers.authorization',
'req.headers.x-sig-sig',
'req.headers.x-ingest-key',
'req.body.api_key',
'req.body.signature',
],
},
trustProxy: process.env.TRUSTED_PROXY
? process.env.TRUSTED_PROXY.split(',').map((value) => value.trim())
: false,
bodyLimit: 1024 * 1024,
})
await app.register(rateLimit, { max: 120, timeWindow: '1 minute' })
await app.register(cors, { origin: c.config.frontend_url.split(',').map((s) => s.trim()) })
await app.register(websocket)
app.setErrorHandler((error, req, reply) => {
if (error instanceof ZodError)
return reply
.code(422)
.send({ detail: error.issues.map((i) => ({ path: i.path, message: i.message })) })
const code =
typeof error === 'object' &&
error !== null &&
'statusCode' in error &&
typeof error.statusCode === 'number'
? error.statusCode
: 500
const message = error instanceof Error ? error.message : 'Request failed'
if (code >= 500)
req.log.error(
{
err: {
name: error instanceof Error ? error.name : 'Error',
message: 'Server operation failed',
},
},
'Request failed',
)
return reply.code(code).send({ detail: code >= 500 ? 'Server operation failed' : message })
})
await registerPosts(app, c.db)
await registerMacro(app, c.db)
await registerAccounts(app, c)
await registerPositions(app, c, s.engine)
await registerSignals(app, c, s)
await registerKol(app, c, s.kol)
await registerTelegram(app, c, s.telegram)
if (c.config.environment === 'development') await registerDev(app, c, s)
app.get('/ws', { websocket: true }, (socket) => {
const unsub = c.events.subscribe((event) => {
if (socket.readyState !== 1 || socket.bufferedAmount > 1024 * 1024) {
socket.terminate()
return
}
socket.send(JSON.stringify(event))
})
const timer = setInterval(() => {
if (socket.readyState !== 1) {
clearInterval(timer)
unsub()
return
}
socket.ping()
}, 30000)
socket.on('close', () => {
clearInterval(timer)
unsub()
})
socket.on('error', () => {
clearInterval(timer)
unsub()
})
})
app.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }))
app.get('/api/health/deep', async () => {
const leader = await s.state.get<{ last_seen: number }>('worker:heartbeat', { last_seen: 0 }),
binance = await s.state.get<{ last_tick_at?: string }>('health:binance', {}),
cnn = await s.state.get<{ last_successful_poll_at?: string }>('health:cnn', {}),
rss = await s.state.get<{ last_successful_poll_at?: string }>('health:rss', {}),
problems: string[] = []
if (Date.now() - leader.last_seen > 90000) problems.push('worker_not_running')
if (!binance.last_tick_at || Date.now() - Date.parse(binance.last_tick_at) > 90000)
problems.push('binance_stale')
return {
status: problems.length ? 'degraded' : 'ok',
is_leader: Date.now() - leader.last_seen < 90000,
problems,
binance,
truth_social: cnn,
trumpstruth: rss,
}
})
return app
}
+29
View File
@@ -0,0 +1,29 @@
import { createDatabase } from '@trumpsignal/database'
import { readConfig } from '@trumpsignal/backend/config'
import { createContext } from '@trumpsignal/backend/context'
import { createServices } from '@trumpsignal/backend/services'
import { startBridge } from '@trumpsignal/backend/bridge'
import { createApp } from './app'
const config = readConfig(),
database = createDatabase(config.database_url),
context = createContext(database, config),
services = createServices(context)
await services.prices.backfill()
const stopBridge = await startBridge(context),
app = await createApp(context, services)
let stopping = false
async function shutdown() {
if (stopping) return
stopping = true
await app.close()
await stopBridge()
await database.pool.end()
await database.lockPool.end()
}
process.once('SIGINT', () => {
void shutdown()
})
process.once('SIGTERM', () => {
void shutdown()
})
await app.listen({ host: process.env.HOST ?? '0.0.0.0', port: Number(process.env.PORT ?? 8000) })
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { subscriptions } from '@trumpsignal/database'
import type { Context } from '@trumpsignal/backend/context'
import type { RawBody } from '@trumpsignal/backend/auth'
import { requireThat, utcMs } from '@trumpsignal/backend/http'
import {
subscribe,
publicUser,
privateUser,
setKey,
clearAndArm,
closedTrades,
performance,
subscription,
settingsDto,
} from '@trumpsignal/backend/accounts'
const body = (r: FastifyRequest) => z.record(z.unknown()).parse(r.body) as RawBody
const wallet = (r: FastifyRequest) =>
z
.string()
.parse((r.params as { wallet: string }).wallet)
.toLowerCase()
.trim()
const settingsSchema = z.object({
leverage: z.number().int().min(1).max(50),
position_size_usd: z.number().min(5).max(10000),
take_profit_pct: z.number().min(0.1).max(50).nullable().optional(),
stop_loss_pct: z.number().min(0.1).max(50).nullable().optional(),
min_confidence: z.number().int().min(0).max(100),
daily_budget_usd: z.number().positive().max(100000).nullable().optional(),
sys2_leverage: z.number().int().min(1).max(10).nullable().optional(),
sys2_mode: z.enum(['standard', 'aggressive', '']).nullable().optional(),
active_from: z.string().nullable().optional(),
active_until: z.string().nullable().optional(),
trump_enabled: z.boolean().nullable().optional(),
macro_enabled: z.boolean().nullable().optional(),
})
export async function registerAccounts(app: FastifyInstance, c: Context) {
app.post('/api/subscribe', async (req) => {
const raw = body(req)
const paper = z.boolean().optional().parse(raw.paper_mode) ?? false
const w = await c.auth.write('subscribe', raw, paper ? { paper_mode: true } : null)
return subscribe(c, w, paper)
})
app.get('/api/user/:wallet/public', async (req) => publicUser(c, wallet(req)))
app.get('/api/user/:wallet', async (req) => {
const w = await c.auth.read(['view_user'], wallet(req), req.headers, req.query as RawBody)
return privateUser(c, w)
})
app.put('/api/user/:wallet/hl-api-key', async (req) => {
const raw = body(req),
key = z.string().parse(raw.api_key).trim()
const w = await c.auth.write('set_hl_api_key', raw, { api_key: key }, wallet(req))
return setKey(c, w, key)
})
app.put('/api/user/:wallet/settings', async (req) => {
const raw = body(req),
s = settingsSchema.parse(raw.settings)
if (s.trump_enabled)
requireThat(
s.take_profit_pct != null && s.stop_loss_pct != null,
422,
'TP and SL required when Trump module is enabled',
)
const af = s.active_from ? utcMs(s.active_from) : null,
au = s.active_until ? utcMs(s.active_until) : null
requireThat(
(af == null) === (au == null) &&
(af == null || (Number.isFinite(af) && Number.isFinite(au) && au! > af)),
422,
'Invalid active window',
)
const w = await c.auth.write('set_settings', raw, raw.settings, wallet(req))
const sub = await subscription(c, w)
const values = {
leverage: s.leverage,
position_size_usd: s.position_size_usd,
take_profit_pct: s.take_profit_pct ?? null,
stop_loss_pct: s.stop_loss_pct ?? null,
min_confidence: s.min_confidence,
daily_budget_usd: s.daily_budget_usd ?? null,
sys2_leverage: s.sys2_leverage ?? null,
active_from: af == null ? null : new Date(af).toISOString().slice(0, -1),
active_until: au == null ? null : new Date(au).toISOString().slice(0, -1),
...(s.sys2_mode != null ? { sys2_mode: s.sys2_mode || 'standard' } : {}),
...(s.trump_enabled != null ? { trump_enabled: s.trump_enabled } : {}),
...(s.macro_enabled != null ? { macro_enabled: s.macro_enabled } : {}),
}
await c.db.update(subscriptions).set(values).where(eq(subscriptions.id, sub.id))
return {
...s,
active_from: af == null ? null : new Date(af).toISOString(),
active_until: au == null ? null : new Date(au).toISOString(),
}
})
app.post('/api/user/:wallet/manual-window', async (req) => {
const raw = body(req),
hours = z.number().int().min(0).max(168).parse(raw.hours)
const w = await c.auth.write('set_manual_window', raw, { hours }, wallet(req))
return clearAndArm(c, w, { hours })
})
app.post('/api/user/:wallet/auto-trade', async (req) => {
const raw = body(req),
enabled = z.boolean().parse(raw.enabled)
const w = await c.auth.write('set_auto_trade', raw, { enabled }, wallet(req))
return clearAndArm(c, w, { enabled })
})
app.get('/api/trades', async (req) => {
const q = z
.object({
wallet: z.string(),
limit: z.coerce.number().int().min(1).max(500).default(20),
page: z.coerce.number().int().min(1).default(1),
})
.passthrough()
.parse(req.query)
const w = await c.auth.read(['view_trades', 'view_user'], q.wallet, req.headers, q)
return closedTrades(c, w, q.limit, q.page)
})
app.get('/api/performance', async (req) => {
const q = z
.object({ wallet: z.string(), include_paper: z.enum(['true', 'false']).optional() })
.passthrough()
.parse(req.query)
const w = await c.auth.read(['view_performance', 'view_user'], q.wallet, req.headers, q)
return performance(c, w, q.include_paper === 'true')
})
}
+137
View File
@@ -0,0 +1,137 @@
import type { FastifyInstance } from 'fastify'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { eq } from 'drizzle-orm'
import { posts, subscriptions } from '@trumpsignal/database'
import type { Context } from '@trumpsignal/backend/context'
import type { Services } from '@trumpsignal/backend/services'
import { postDto } from '@trumpsignal/backend/post-dto'
import { utcNow, requireThat } from '@trumpsignal/backend/http'
import {
backtestPost,
backtestBatch,
backfillImpact,
reanalyze,
} from '@trumpsignal/backend/operations'
const qSchema = z.object({
post_id: z.coerce.number().int().positive().optional(),
stop_loss_pct: z.coerce.number().min(0.01).max(100).default(1.5),
trailing_stop_pct: z.coerce.number().min(0).default(2.5),
trailing_activate_at_pct: z.coerce.number().min(0).default(5),
take_profit_pct: z.coerce.number().min(0).default(0),
max_hold_hours: z.coerce.number().int().min(1).max(12960).default(168),
limit: z.coerce.number().int().min(1).max(500).default(50),
min_confidence: z.coerce.number().int().min(0).max(100).default(80),
})
export async function registerDev(app: FastifyInstance, c: Context, s: Services) {
app.post('/api/dev/fake-post', async (req) => {
const q = z
.object({
text: z.string().default('BITCOIN is the future of money!'),
sentiment: z.enum(['bullish', 'bearish', 'neutral']).default('bullish'),
})
.parse(req.query),
[p] = await c.db
.insert(posts)
.values({
...q,
external_id: randomBytes(16).toString('hex'),
source: 'truth',
published_at: utcNow(),
ai_confidence: 88,
relevant: true,
price_impact_asset: 'BTC',
price_at_post: 94230,
price_impact_m5: 1.2,
price_impact_m15: 2.4,
price_impact_m1h: 3.1,
})
.returning()
c.events.broadcast({ type: 'new_post', post: postDto(p) })
return { id: p.id, text: p.text.slice(0, 80) }
})
app.post('/api/dev/fake-signal', async (req) => {
const q = z
.object({
symbol: z.string().default('ETHUSDT'),
close: z.coerce.number().default(1850.42),
tbr: z.coerce.number().default(0.72),
vol_mult: z.coerce.number().default(3.1),
bb_pct: z.coerce.number().default(8.5),
btc_trend: z.string().default('↑ uptrend'),
})
.parse(req.query),
enabled = await s.state.get('breakout-enabled', false),
alert = {
type: 'funding_signal',
...q,
time: new Date().toISOString(),
bb_upper: q.close * 1.002,
enabled,
}
await s.state.change<unknown[]>('breakout-history', [], (history) =>
[...history, alert].slice(-100),
)
if (enabled) c.events.broadcast(alert)
return { status: enabled ? 'broadcast' : 'recorded_only (monitor OFF)', alert }
})
app.post('/api/dev/reanalyze', async (req) => {
const q = z
.object({
limit: z.coerce.number().int().min(1).max(10000).default(500),
dry_run: z.enum(['true', 'false']).default('false'),
delay_secs: z.coerce.number().min(0).max(60).default(0.5),
legacy_signals: z.enum(['true', 'false']).default('false'),
model: z.string().optional(),
})
.parse(req.query),
state = await s.state.get<{ running: boolean }>('reanalyze', { running: false })
if (state.running) return { status: 'already_running', state }
const options = {
...q,
dry_run: q.dry_run === 'true',
legacy_signals: q.legacy_signals === 'true',
}
void reanalyze(c, s, options).catch(() => {})
return { status: 'started', ...options }
})
app.get('/api/dev/reanalyze/status', async () =>
s.state.get('reanalyze', { running: false, processed: 0, errors: 0 }),
)
app.post('/api/dev/backfill-prices', async (req) => {
const q = z.object({ asset: z.enum(['BTC', 'ETH']).default('BTC') }).parse(req.query)
void backfillImpact(c, q.asset).catch(() => {})
return { status: 'started', asset: q.asset }
})
for (const mode of ['post', 'batch'] as const)
app.post('/api/dev/backtest/' + mode, async (req) => {
const q = qSchema.parse(req.query),
params = {
stop_loss_pct: q.stop_loss_pct,
trailing_stop_pct: q.trailing_stop_pct || null,
trailing_activate_at_pct: q.trailing_activate_at_pct || null,
take_profit_pct: q.take_profit_pct || null,
max_hold_hours: q.max_hold_hours,
}
if (mode === 'batch') return backtestBatch(c, q.limit, q.min_confidence, params)
requireThat(q.post_id, 422, 'post_id required')
const result = await backtestPost(c, q.post_id, params)
return result ? { status: 'ok', result } : { status: 'skipped', post_id: q.post_id }
})
app.post('/api/dev/paper-mode', async (req) => {
const q = z.object({ wallet: z.string(), enabled: z.enum(['true', 'false']) }).parse(req.query),
[s] = await c.db
.update(subscriptions)
.set({
paper_mode: q.enabled === 'true',
...(q.enabled === 'false' ? { auto_trade: false } : {}),
})
.where(eq(subscriptions.wallet_address, q.wallet.toLowerCase().trim()))
.returning()
return {
status: s ? 'ok' : 'not_found',
wallet: q.wallet.toLowerCase().trim(),
paper_mode: q.enabled === 'true',
}
})
}
+182
View File
@@ -0,0 +1,182 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { and, eq, gte, desc, asc, count, sql, or, ne, isNull } from 'drizzle-orm'
import {
kol_posts as posts,
kol_wallets as wallets,
kol_holdings_snapshots as snapshots,
kol_holding_changes as changes,
kol_divergence as divergence,
} from '@trumpsignal/database'
import type { Context } from '@trumpsignal/backend/context'
import { Kol, kolDto, parseArray } from '@trumpsignal/backend/kol'
import { requireThat, iso } from '@trumpsignal/backend/http'
import { requireIngestKey } from './signals'
const query = z.object({
handle: z.string().optional(),
source: z.string().optional(),
ticker: z
.string()
.regex(/^[A-Za-z0-9]{1,12}$/)
.optional(),
signal_type: z.string().optional(),
signals_only: z.enum(['true', 'false']).optional(),
days: z.coerce.number().int().min(1).max(365).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
page: z.coerce.number().int().min(1).default(1),
lookback_days: z.coerce.number().int().min(1).max(90).default(30),
})
const since = (days: number) => new Date(Date.now() - days * 86400000).toISOString().slice(0, -1)
const walletDto = (w: typeof wallets.$inferSelect) => ({ ...w, added_at: iso(w.added_at) })
export async function registerKol(app: FastifyInstance, c: Context, kol: Kol) {
app.get('/api/kol/posts', async (req) => {
const q = query.parse(req.query),
scope = and(
q.handle ? eq(posts.kol_handle, q.handle) : undefined,
q.source ? eq(posts.source, q.source) : undefined,
q.signals_only === 'true' ? or(isNull(posts.tier), ne(posts.tier, 'noise')) : undefined,
q.ticker
? sql`${posts.tickers_json}::jsonb @> ${JSON.stringify([{ ticker: q.ticker.toUpperCase() }])}::jsonb`
: undefined,
q.days ? gte(posts.published_at, since(q.days)) : undefined,
)
const [rows, totals] = await Promise.all([
c.db
.select()
.from(posts)
.where(scope)
.orderBy(desc(posts.published_at))
.limit(q.limit)
.offset((q.page - 1) * q.limit),
c.db.select({ n: count() }).from(posts).where(scope),
])
return { items: rows.map(kolDto), page: q.page, limit: q.limit, total: totals[0].n }
})
app.get<{ Params: { id: string } }>('/api/kol/posts/:id', async (req) => {
const [p] = await c.db
.select()
.from(posts)
.where(eq(posts.id, z.coerce.number().int().positive().parse(req.params.id)))
.limit(1)
requireThat(p, 404, 'KOL post not found')
return { ...kolDto(p), raw_text: p.raw_text }
})
app.get('/api/kol/digest', async (req) => {
const q = query.parse(req.query)
return kol.digest(Math.min(q.days ?? 7, 90))
})
app.get('/api/kol/wallets', async (req) => {
const q = query.parse(req.query),
rows = await c.db
.select()
.from(wallets)
.where(and(eq(wallets.active, true), q.handle ? eq(wallets.handle, q.handle) : undefined))
.orderBy(asc(wallets.handle))
return { wallets: rows.map(walletDto) }
})
app.post('/api/kol/wallets', async (req) => {
requireIngestKey(c, req.headers['x-ingest-key'])
const b = z
.object({
handle: z.string().min(1).max(64),
chain: z.string().min(1).max(16),
address: z.string().min(1).max(128),
label: z.string().max(128).nullable().optional(),
source_url: z.string().max(256).nullable().optional(),
})
.parse(req.body)
const [row] = await c.db
.insert(wallets)
.values({ ...b, address: b.address.toLowerCase() })
.onConflictDoNothing()
.returning()
requireThat(row, 409, 'Wallet already tracked')
return walletDto(row)
})
app.get<{ Params: { id: string } }>('/api/kol/wallets/:id/snapshots', async (req) => {
const id = z.coerce.number().int().positive().parse(req.params.id),
q = query.parse(req.query),
[w] = await c.db.select().from(wallets).where(eq(wallets.id, id))
requireThat(w, 404, 'Wallet not found')
const rows = await c.db
.select()
.from(snapshots)
.where(eq(snapshots.wallet_id, id))
.orderBy(desc(snapshots.snapshot_date))
.limit(Math.min(q.limit, 90))
return {
wallet: walletDto(w),
snapshots: rows.map((s) => ({
id: s.id,
wallet_id: s.wallet_id,
snapshot_date: s.snapshot_date,
holdings: parseArray(s.holdings_json),
total_usd: s.total_usd,
source: s.source,
created_at: iso(s.created_at),
})),
}
})
app.get('/api/kol/changes', async (req) => {
const q = query.parse(req.query),
days = Math.min(q.days ?? 7, 90),
cutoff = since(days),
rows = await c.db
.select({ change: changes, handle: wallets.handle })
.from(changes)
.innerJoin(wallets, eq(changes.wallet_id, wallets.id))
.where(
and(
gte(changes.detected_at, cutoff),
q.handle ? eq(wallets.handle, q.handle) : undefined,
),
)
.orderBy(desc(changes.detected_at))
.limit(200)
return {
window_days: days,
since: iso(cutoff),
count: rows.length,
changes: rows.map(({ change, handle }) => ({
...change,
handle,
detected_at: iso(change.detected_at),
})),
}
})
app.get('/api/kol/divergence', async (req) => {
const q = query.parse(req.query),
days = Math.min(q.days ?? 30, 180),
cutoff = since(days),
rows = await c.db
.select()
.from(divergence)
.where(
and(
gte(divergence.post_at, cutoff),
q.handle ? eq(divergence.handle, q.handle) : undefined,
q.ticker ? eq(divergence.ticker, q.ticker.toUpperCase()) : undefined,
q.signal_type ? eq(divergence.signal_type, q.signal_type) : undefined,
),
)
.orderBy(desc(divergence.post_at), desc(divergence.created_at))
.limit(200)
return {
window_days: days,
since: iso(cutoff),
count: rows.length,
items: rows.map((d) => ({
...d,
post_at: iso(d.post_at),
onchain_at: iso(d.onchain_at),
created_at: iso(d.created_at),
})),
}
})
app.post('/api/kol/divergence/scan', async (req) => {
requireIngestKey(c, req.headers['x-ingest-key'])
const q = query.parse(req.query),
items = await kol.divergence(q.lookback_days)
return { new_pairs: items.length, items }
})
}
+54
View File
@@ -0,0 +1,54 @@
import type { FastifyInstance } from 'fastify'
import { desc, asc, gte } from 'drizzle-orm'
import { macro_snapshots as snapshots, type createDatabase } from '@trumpsignal/database'
import { iso } from './posts'
const keys = [
'ahr999',
'altcoin_season_index',
'fear_greed',
'fear_greed_label',
'btc_dominance_pct',
'eth_btc_ratio',
'stablecoin_supply_usd',
'etf_flow_net_usd_1d',
'btc_open_interest_usd',
] as const
function dto(row: typeof snapshots.$inferSelect) {
return {
date: row.snapshot_date,
captured_at: iso(row.captured_at),
indicators: Object.fromEntries(keys.map((k) => [k, row[k]])),
composite_score: row.composite_score,
regime_label: row.regime_label,
}
}
export async function registerMacro(
app: FastifyInstance,
db: ReturnType<typeof createDatabase>['db'],
) {
app.get('/api/macro/snapshot', async () => {
const [r] = await db.select().from(snapshots).orderBy(desc(snapshots.snapshot_date)).limit(1)
return r ? { ok: true, ...dto(r) } : { ok: false, error: 'no snapshots yet — poll has not run' }
})
app.get<{ Querystring: { days: number } }>(
'/api/macro/history',
{
schema: {
querystring: {
type: 'object',
properties: { days: { type: 'integer', minimum: 1, maximum: 365, default: 30 } },
},
},
},
async (req) => {
const days = req.query.days
const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)
const rows = await db
.select()
.from(snapshots)
.where(gte(snapshots.snapshot_date, cutoff))
.orderBy(asc(snapshots.snapshot_date))
return { ok: true, days, count: rows.length, items: rows.map(dto) }
},
)
}
+143
View File
@@ -0,0 +1,143 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { and, eq, isNull, gte, isNotNull, desc } from 'drizzle-orm'
import { bot_trades as trades } from '@trumpsignal/database'
import type { Context } from '@trumpsignal/backend/context'
import type { Engine } from '@trumpsignal/backend/engine'
import { requireThat, round, iso, utcMs } from '@trumpsignal/backend/http'
import type { RawBody } from '@trumpsignal/backend/auth'
const raw = (r: FastifyRequest) => z.record(z.unknown()).parse(r.body)
const id = (r: FastifyRequest) =>
z.coerce
.number()
.int()
.positive()
.parse((r.params as { id: string }).id)
export async function registerPositions(app: FastifyInstance, c: Context, engine: Engine) {
async function read(req: FastifyRequest, path = false) {
const q = z.record(z.unknown()).parse(req.query),
wallet = z.string().parse(path ? (req.params as { wallet: string }).wallet : q.wallet)
return c.auth.read(['view_positions', 'view_user'], wallet, req.headers, q)
}
app.get('/api/positions/open', async (req) => {
const wallet = await read(req),
rows = await c.db
.select()
.from(trades)
.where(
and(
eq(trades.wallet_address, wallet),
isNull(trades.closed_at),
isNull(trades.released_at),
),
)
.orderBy(desc(trades.opened_at))
return {
wallet,
count: rows.length,
positions: rows.map((t) => {
const px = c.prices.latest(t.asset),
notional = round((t.size_usd ?? 0) * (t.remaining_fraction ?? 1)),
pct =
px != null
? ((px - t.entry_price) / t.entry_price) * (t.side === 'long' ? 1 : -1) * 100
: null
return {
trade_id: t.id,
asset: t.asset,
side: t.side,
entry_price: t.entry_price,
current_price: px,
size_usd: notional,
leverage: t.leverage,
opened_at: iso(t.opened_at),
hold_minutes: Math.floor((Date.now() - utcMs(t.opened_at)) / 60000),
unrealized_pct: pct == null ? null : round(pct, 3),
unrealized_usd: pct == null || !notional ? null : round((notional * pct) / 100),
is_paper: t.hl_order_id === 'paper',
trigger_post_id: t.trigger_post_id,
realized_usd: t.realized_partial_pnl_usd ? round(t.realized_partial_pnl_usd) : null,
derisk_steps: t.derisk_steps_done,
addon_steps: t.addon_steps_done,
grow_mode: t.grow_mode,
}
}),
}
})
app.get('/api/positions/today', async (req) => {
const wallet = await read(req)
const [closed, opened] = await Promise.all([
c.db
.select()
.from(trades)
.where(
and(
eq(trades.wallet_address, wallet),
gte(trades.closed_at, new Date().toISOString().slice(0, 10)),
isNotNull(trades.pnl_usd),
isNull(trades.released_at),
),
),
c.db
.select()
.from(trades)
.where(
and(
eq(trades.wallet_address, wallet),
isNull(trades.closed_at),
isNull(trades.released_at),
),
),
])
return {
wallet,
realized_pnl_usd: round(closed.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)),
trades_closed: closed.length,
wins: closed.filter((t) => t.pnl_usd! > 0).length,
losses: closed.filter((t) => t.pnl_usd! < 0).length,
open_count: opened.length,
open_realized_usd: round(opened.reduce((s, t) => s + t.realized_partial_pnl_usd, 0)),
}
})
app.get('/api/positions/hl/:wallet', async (req) => {
const wallet = await read(req, true),
positions = await engine.listHl(wallet)
return { wallet, count: positions.length, positions }
})
app.post('/api/positions/adopt', async (req) => {
const body = raw(req),
asset = z.string().min(1).parse(body.asset).trim().toUpperCase(),
mode = z.enum(['standard', 'aggressive']).default('standard').parse(body.mode),
wallet = await c.auth.write('adopt_position', body, { asset, mode })
return engine.adopt(wallet, asset, mode)
})
app.post('/api/positions/:id/release', async (req) => {
const body = raw(req),
trade_id = id(req)
requireThat(body.trade_id === trade_id, 400, 'trade_id mismatch')
const w = await c.auth.write('release_trade', body, { trade_id })
return engine.release(w, trade_id)
})
app.post('/api/positions/:id/grow', async (req) => {
const body = raw(req),
trade_id = id(req),
enabled = z.boolean().parse(body.enabled)
requireThat(body.trade_id === trade_id, 400, 'trade_id mismatch')
const w = await c.auth.write('set_trade_grow', body, { trade_id, enabled })
return engine.grow(w, trade_id, enabled)
})
app.post('/api/positions/:id/close', async (req) => {
const body = raw(req),
trade_id = id(req),
w = await c.auth.write('close_trade', body, { trade_id })
const t = await engine.close(trade_id, 'manual', true, w)
requireThat(t.closed_at, 502, 'Position could not be closed')
return {
status: 'ok',
trade_id,
exit_price: t.exit_price,
pnl_usd: t.pnl_usd,
reason: 'manual',
}
})
}
+255
View File
@@ -0,0 +1,255 @@
import type { FastifyInstance } from 'fastify'
import { and, eq, notInArray, inArray, desc, asc, count, sql, max, type SQL } from 'drizzle-orm'
import { posts, type createDatabase } from '@trumpsignal/database'
import { isSupportedTradingSource } from '@trumpsignal/core/signal-categories'
type Db = ReturnType<typeof createDatabase>['db']
type Post = typeof posts.$inferSelect
type Query = {
limit?: number
page?: number
source?: string
source_in?: string
source_not_in?: string
archive_only?: boolean
sentiment?: string
signal?: string
ai_scored_only?: boolean
}
const scored = sql`(coalesce(${posts.ai_confidence}, 0) > 0 or ${posts.ai_reasoning} is not null)`
export function postDto(p: Post) {
const correct = (v: number | null) =>
v == null
? null
: p.signal === 'buy'
? v > 0
: ['short', 'sell'].includes(p.signal ?? '')
? v < 0
: null
return {
id: p.id,
text: p.text,
source: p.source,
published_at: iso(p.published_at),
sentiment: p.sentiment,
signal: p.signal,
ai_confidence: p.ai_confidence,
ai_reasoning: p.ai_reasoning,
prefilter_reason: p.prefilter_reason,
analysis_version: p.analysis_version,
relevant: p.relevant,
target_asset: p.target_asset,
category: p.category,
expected_move_pct: p.expected_move_pct,
invalidation_price: p.invalidation_price,
price_impact:
p.price_impact_asset && p.price_at_post != null
? {
asset: p.price_impact_asset,
price_at_post: p.price_at_post,
m5: p.price_impact_m5,
m15: p.price_impact_m15,
m1h: p.price_impact_m1h,
correct_m5: correct(p.price_impact_m5),
correct_m15: correct(p.price_impact_m15),
correct_m1h: correct(p.price_impact_m1h),
}
: null,
}
}
export function iso(value: string | null): string | null {
return value == null
? null
: /(?:Z|[+-]\d\d:\d\d)$/.test(value)
? new Date(value).toISOString()
: new Date(value + 'Z').toISOString()
}
const querySchema = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 500, default: 20 },
page: { type: 'integer', minimum: 1, default: 1 },
source: { type: 'string' },
source_in: { type: 'string' },
source_not_in: { type: 'string' },
archive_only: { type: 'boolean', default: false },
sentiment: { type: 'string', enum: ['bullish', 'bearish', 'neutral'] },
signal: { type: 'string', enum: ['buy', 'short', 'actionable'] },
ai_scored_only: { type: 'boolean', default: false },
},
}
const csv = (s?: string) =>
(s ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
export async function registerPosts(app: FastifyInstance, db: Db) {
app.get<{ Querystring: Query }>(
'/api/posts',
{
schema: { querystring: querySchema },
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (req, reply) => {
const q = req.query,
limit = q.limit ?? 20,
page = q.page ?? 1
const rows = await db
.select()
.from(posts)
.where(q.source ? eq(posts.source, q.source) : undefined)
.orderBy(desc(posts.published_at))
.limit(limit)
.offset((page - 1) * limit)
reply.header('cache-control', 'public, max-age=30, stale-while-revalidate=60')
return rows.map(postDto)
},
)
app.get<{ Querystring: Query }>(
'/api/posts-paged',
{
schema: { querystring: querySchema },
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (req, reply) => {
const q = req.query,
limit = q.limit ?? 20,
page = q.page ?? 1
const base: SQL[] = []
const chips: SQL[] = []
if (q.source) {
base.push(eq(posts.source, q.source))
chips.push(eq(posts.source, q.source))
} else if (csv(q.source_in).length) base.push(inArray(posts.source, csv(q.source_in)))
const excluded = [
...csv(q.source_not_in),
...(q.archive_only
? ['truth', 'btc_bottom_reversal', 'funding_reversal', 'kol_divergence']
: []),
]
if (excluded.length) {
const e = notInArray(posts.source, excluded)
base.push(e)
chips.push(e)
}
if (q.sentiment) {
const e = eq(posts.sentiment, q.sentiment)
base.push(e)
chips.push(e)
}
const list = [...base]
if (q.ai_scored_only) {
list.push(scored)
chips.push(scored)
}
if (q.signal)
list.push(
q.signal === 'actionable'
? inArray(posts.signal, ['buy', 'short'])
: eq(posts.signal, q.signal),
)
const [items, totalRows, countsRows, sources] = await Promise.all([
db
.select()
.from(posts)
.where(and(...list))
.orderBy(desc(posts.published_at))
.limit(limit)
.offset((page - 1) * limit),
db
.select({ n: count() })
.from(posts)
.where(and(...list)),
db
.select({
all: count(),
actionable: sql<number>`sum(case when ${posts.signal} in ('buy','short') then 1 else 0 end)`,
buy: sql<number>`sum(case when ${posts.signal} = 'buy' then 1 else 0 end)`,
short: sql<number>`sum(case when ${posts.signal} = 'short' then 1 else 0 end)`,
off_topic: sql<number>`sum(case when ${scored} then 0 else 1 end)`,
})
.from(posts)
.where(and(...base)),
db
.select({ source: posts.source, count: count(), latest: max(posts.published_at) })
.from(posts)
.where(and(...chips))
.groupBy(posts.source)
.orderBy(desc(count()), asc(posts.source)),
])
const c = countsRows[0]
const off = Number(c.off_topic ?? 0)
reply.header('cache-control', 'public, max-age=30, stale-while-revalidate=60')
return {
items: items.map(postDto),
total: totalRows[0].n,
page,
limit,
counts: {
all: Math.max(0, Number(c.all) - (q.ai_scored_only ? off : 0)),
actionable: Number(c.actionable ?? 0),
buy: Number(c.buy ?? 0),
short: Number(c.short ?? 0),
off_topic: off,
},
source_counts: sources.map((s) => ({ ...s, latest: iso(s.latest) })),
}
},
)
app.get<{ Params: { id: number } }>(
'/api/posts/:id',
{
schema: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
},
},
},
async (req, reply) => {
const [p] = await db.select().from(posts).where(eq(posts.id, req.params.id)).limit(1)
if (!p) return reply.code(404).send({ detail: 'Post not found' })
return postDto(p)
},
)
app.get('/api/signals/accuracy', async () => {
const rows = (
await db
.select()
.from(posts)
.where(inArray(posts.signal, ['buy', 'short']))
).filter((p) => isSupportedTradingSource(p.source))
const bucket = () => ({ checked: 0, correct: 0 })
const group = () => ({ m5: bucket(), m15: bucket(), m1h: bucket() })
const overall = group()
const by: Record<string, ReturnType<typeof group> & { count: number }> = {}
for (const p of rows) {
const s = p.signal!
const g = by[s] ?? (by[s] = { ...group(), count: 0 })
g.count++
for (const win of ['m5', 'm15', 'm1h'] as const) {
const v = p[`price_impact_${win}`]
if (v == null) continue
const ok = s === 'buy' ? v > 0 : v < 0
for (const b of [overall[win], g[win]]) {
b.checked++
b.correct += Number(ok)
}
}
}
const finish = (b: ReturnType<typeof bucket>) => ({
...b,
accuracy_pct: b.checked ? Math.round((b.correct / b.checked) * 1000) / 10 : null,
})
return {
overall: Object.fromEntries(Object.entries(overall).map(([k, b]) => [k, finish(b)])),
by_signal: Object.fromEntries(
Object.entries(by).map(([s, g]) => [
s,
{ count: g.count, m5: finish(g.m5), m15: finish(g.m15), m1h: finish(g.m1h) },
]),
),
total_directional_signals: rows.length,
}
})
}
+107
View File
@@ -0,0 +1,107 @@
import { timingSafeEqual } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { eq, count, max, desc } from 'drizzle-orm'
import { posts } from '@trumpsignal/database'
import type { Context } from '@trumpsignal/backend/context'
import type { Services } from '@trumpsignal/backend/services'
import { requireThat, iso } from '@trumpsignal/backend/http'
import { subscription } from '@trumpsignal/backend/accounts'
import { marketBars } from '@trumpsignal/backend/market-data'
export function requireIngestKey(c: Context, key: unknown) {
const expected = c.config.ingest_api_key
requireThat(expected, 503, 'INGEST_API_KEY not configured')
requireThat(
typeof key === 'string' &&
Buffer.byteLength(key) === Buffer.byteLength(expected) &&
timingSafeEqual(Buffer.from(key), Buffer.from(expected)),
401,
'Invalid X-Ingest-Key',
)
}
export async function registerSignals(app: FastifyInstance, c: Context, s: Services) {
app.post('/api/signals/ingest', async (req) => {
requireIngestKey(c, req.headers['x-ingest-key'])
return s.pipeline.signal(req.body)
})
app.get('/api/signals/sources', async () => ({
sources: (
await c.db
.select({ source: posts.source, count: count(), latest: max(posts.published_at) })
.from(posts)
.groupBy(posts.source)
.orderBy(desc(count()))
).map((r) => ({ ...r, latest: iso(r.latest) })),
}))
app.get('/api/scanners', async () => {
const scanners = await s.scannerState.all()
return { count: scanners.length, enabled: scanners.filter((s) => s.enabled).length, scanners }
})
app.post<{ Params: { name: string } }>('/api/scanners/:name/toggle', async (req) => {
const raw = z.record(z.unknown()).parse(req.body),
name = req.params.name,
enabled = z.boolean().parse(raw.enabled)
requireThat(raw.name === name, 400, 'Scanner name mismatch')
const wallet = await c.auth.write('toggle_scanner', raw, { enabled, name })
await subscription(c, wallet)
const result = await s.scannerState.enabled(name, enabled)
requireThat(result, 404, 'Unknown scanner')
return { status: 'ok', name, enabled }
})
for (const action of ['disable', 'enable'] as const)
app.post('/api/scanners/all/' + action, async (req) => {
const raw = z.record(z.unknown()).parse(req.body),
wallet = await c.auth.write(
action === 'enable' ? 'scanner_revive_all' : 'scanner_kill_all',
raw,
null,
)
await subscription(c, wallet)
const list = await s.scannerState.all()
for (const scanner of list) await s.scannerState.enabled(scanner.name, action === 'enable')
return { status: action === 'enable' ? 'revived' : 'killed', count: list.length }
})
app.get('/api/funding/snapshot', async () => s.scanners.fundingSnapshot())
app.get('/api/signal/status', async () => ({
enabled: await s.state.get('breakout-enabled', false),
symbols: ['ETHUSDT', 'LINKUSDT'],
recent_signal_count: (await s.state.get<unknown[]>('breakout-history', [])).length,
}))
app.get('/api/signal/history', async (req) => {
const q = z
.object({ limit: z.coerce.number().int().min(1).max(200).default(20) })
.parse(req.query)
return (await s.state.get<unknown[]>('breakout-history', []))
.slice()
.reverse()
.slice(0, q.limit)
})
app.post('/api/signal/toggle', async (req) => {
requireIngestKey(c, req.headers['x-ingest-key'])
const q = z.object({ enabled: z.enum(['true', 'false']) }).parse(req.query)
await s.state.set('breakout-enabled', q.enabled === 'true')
return { enabled: q.enabled === 'true' }
})
app.get<{ Params: { asset: string } }>(
'/api/prices/:asset',
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (req, reply) => {
const asset = z.enum(['BTC', 'ETH']).parse(req.params.asset.toUpperCase()),
q = z
.object({
tf: z.enum(['1m', '5m', '15m', '1H', '4H', '1D', '1W']).default('4H'),
limit: z.coerce.number().int().min(1).max(1000).default(200),
})
.parse(req.query)
reply.header(
'cache-control',
q.tf === '1m'
? 'public, max-age=5, stale-while-revalidate=10'
: 'public, max-age=60, stale-while-revalidate=120',
)
if (q.tf === '1m') return c.prices.candles(asset, '1m', q.limit)
const bars = await marketBars(c.config, asset, q.tf.toLowerCase(), q.limit)
return bars.map(({ time_ms, ...bar }) => bar)
},
)
}
+49
View File
@@ -0,0 +1,49 @@
import type { FastifyInstance } from 'fastify'
import { z } from 'zod'
import type { Context } from '@trumpsignal/backend/context'
import type { Telegram } from '@trumpsignal/backend/telegram'
import { requireThat } from '@trumpsignal/backend/http'
const preferences = z.object({
alerts_enabled: z.boolean().nullable().optional(),
alert_trump: z.boolean().nullable().optional(),
alert_btc_bottom: z.boolean().nullable().optional(),
alert_funding: z.boolean().nullable().optional(),
alert_kol_divergence: z.boolean().nullable().optional(),
min_confidence: z.number().int().min(0).max(100).nullable().optional(),
mute_from_hour: z.number().int().min(0).max(23).nullable().optional(),
mute_until_hour: z.number().int().min(0).max(23).nullable().optional(),
})
export async function registerTelegram(app: FastifyInstance, c: Context, tg: Telegram) {
app.get<{ Params: { wallet: string } }>('/api/telegram/:wallet/status', async (req) => {
const wallet = req.params.wallet.toLowerCase().trim()
let auth = false
try {
await c.auth.read(
['view_telegram_status', 'view_user'],
wallet,
req.headers,
req.query as Record<string, unknown>,
)
auth = true
} catch {}
return tg.status(wallet, auth)
})
for (const action of ['init', 'unbind', 'test'] as const)
app.post<{ Params: { wallet: string } }>('/api/telegram/:wallet/' + action, async (req) => {
tg.requireConfigured()
const raw = z.record(z.unknown()).parse(req.body),
wallet = await c.auth.write('telegram_' + action, raw, null, req.params.wallet)
if (action === 'init') return tg.issue(wallet)
if (action === 'unbind') return tg.unbind(wallet)
requireThat(await tg.test(wallet), 400, 'Test failed; bind via /start first')
return { sent: true }
})
app.post<{ Params: { wallet: string } }>('/api/telegram/:wallet/preferences', async (req) => {
tg.requireConfigured()
const raw = z.record(z.unknown()).parse(req.body),
parsed = preferences.parse(raw),
values = Object.fromEntries(Object.entries(parsed).filter(([, v]) => v != null))
const wallet = await c.auth.write('telegram_prefs', raw, values, req.params.wallet)
return tg.preferences(wallet, values)
})
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
API_URL=http://localhost:8000
NEXT_PUBLIC_SITE_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@trumpsignal/ui/lib/utils",
"ui": "@trumpsignal/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+67
View File
@@ -0,0 +1,67 @@
import Head from 'next/head'
import type { Metadata } from 'next'
import { siteMetadata } from '@/lib/siteMetadata'
function titleText(value: Metadata['title'], fallback: string): string {
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
if ('absolute' in value && value.absolute) return value.absolute
if ('default' in value && value.default) return value.default
}
return fallback
}
export function PageHead({ seo = {} }: { seo?: Metadata }) {
const title = titleText(seo.title, titleText(siteMetadata.title, 'Trump Alpha'))
const description = seo.description ?? siteMetadata.description ?? ''
const canonical =
seo.alternates?.canonical?.toString() ?? siteMetadata.alternates?.canonical?.toString()
const robots = seo.robots ?? siteMetadata.robots
const robotsText =
typeof robots === 'string'
? robots
: [
robots?.index === false ? 'noindex' : 'index',
robots?.follow === false ? 'nofollow' : 'follow',
].join(',')
const keywords = seo.keywords ?? siteMetadata.keywords
const languages = seo.alternates?.languages ?? siteMetadata.alternates?.languages
return (
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="application-name" content="Trump Alpha" />
{keywords && (
<meta name="keywords" content={Array.isArray(keywords) ? keywords.join(', ') : keywords} />
)}
<meta name="author" content="Endorphin" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f0d0a" />
<meta name="robots" content={robotsText} />
{canonical && <link rel="canonical" href={canonical} />}
{languages &&
Object.entries(languages).map(([language, value]) => (
<link
key={language}
rel="alternate"
hrefLang={language}
href={String(Array.isArray(value) ? value[0]?.url : value)}
/>
))}
<meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Trump Alpha" />
<meta property="og:locale" content="en_US" />
<meta
property="og:image"
content={`${process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'}/opengraph-image`}
/>
<meta property="og:description" content={description} />
{canonical && <meta property="og:url" content={canonical} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon" />
<link rel="apple-touch-icon" href="/apple-icon" />
</Head>
)
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { WagmiProvider } from 'wagmi'
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { config } from '@/lib/wagmi'
import { WsProvider } from '@/lib/wsContext'
interface ProvidersProps {
children: React.ReactNode
}
export default function Providers({ children }: ProvidersProps) {
const [queryClient] = useState(() => new QueryClient())
return (
<WagmiProvider config={config} reconnectOnMount={false}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={darkTheme({
accentColor: '#f97316',
accentColorForeground: '#000000',
borderRadius: 'medium',
overlayBlur: 'small',
})}
>
{/* Single shared WebSocket — all components subscribe via useWsSubscribe */}
<WsProvider>{children}</WsProvider>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
)
}
+740
View File
@@ -0,0 +1,740 @@
'use client'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { getMacroSnapshot, type MacroSnapshot } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import InfoTip from '@/components/ui/InfoTip'
function fmtUsd(n: number | null | undefined, { compact = false } = {}): string {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
if (compact) {
if (abs >= 1e9) return `${(n / 1e9).toFixed(2)}B`
if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`
if (abs >= 1e3) return `${(n / 1e3).toFixed(0)}K`
return n.toFixed(0)
}
return n.toLocaleString('en-US', { maximumFractionDigits: 2 })
}
function fmtSignedUsd(n: number | null | undefined): string {
if (n == null || isNaN(n)) return '—'
const compact = fmtUsd(Math.abs(n), { compact: true })
return (n >= 0 ? '+$' : '-$') + compact
}
function fmtPct(n: number | null | undefined, digits = 2): string {
if (n == null || isNaN(n)) return '—'
return n.toFixed(digits) + '%'
}
type Tone = 'up' | 'down' | 'neutral'
function toneAhr(v: number | null): Tone {
if (v == null) return 'neutral'
if (v < 0.45) return 'up'
if (v > 1.2) return 'down'
return 'neutral'
}
function toneAltseason(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 75) return 'up'
if (v <= 25) return 'down'
return 'neutral'
}
function toneFearGreed(v: number | null): Tone {
if (v == null) return 'neutral'
if (v <= 25) return 'up'
if (v >= 75) return 'down'
return 'neutral'
}
function toneEtfFlow(v: number | null): Tone {
if (v == null) return 'neutral'
if (v > 50_000_000) return 'up'
if (v < -50_000_000) return 'down'
return 'neutral'
}
function toneBtcDominance(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 60 || v <= 40) return 'down'
return 'neutral'
}
function toneEthBtc(v: number | null): Tone {
if (v == null) return 'neutral'
if (v >= 0.04) return 'up'
if (v <= 0.025) return 'down'
return 'neutral'
}
const REGIME_COLOR: Record<string, string> = {
BULL: 'var(--up)',
BULLISH: 'var(--up)',
NEUTRAL: 'var(--ink-3)',
BEARISH: 'var(--down)',
BEAR: 'var(--down)',
}
/**
* Single "Open chart" button per card. Was two (Data source + Chart) — users
* couldn't tell which to click and the dual-link group split attention.
* Defaults to a CoinGlass page; when CoinGlass doesn't host the indicator
* (Pi Cycle Bottom, Stablecoin Supply), we fall back to the canonical site.
*/
function SourceButton({ href, children }: { href: string; children: ReactNode }) {
return (
<a href={href} target="_blank" rel="noreferrer" className="macro-action-btn chart">
<span className="macro-action-copy">
<span className="macro-action-kicker">Open chart</span>
<span className="macro-action-label">{children}</span>
</span>
<span className="macro-action-arrow" aria-hidden="true">
↗
</span>
</a>
)
}
function ThresholdChip({
tone = 'neutral',
active = false,
children,
}: {
tone?: Tone
active?: boolean
children: ReactNode
}) {
const cls = `macro-threshold-chip ${tone}${active ? ' active' : ''}`
return <span className={cls}>{children}</span>
}
function MetricCard({
rank,
label,
value,
tone = 'neutral',
hint,
summary,
thresholds,
activeIndex,
chartHref,
chartLabel = 'CoinGlass',
hero = false,
}: {
rank: number
label: string
value: string
tone?: Tone
/** What the indicator IS, one sentence. Do NOT enumerate threshold bands
* here — the chips below each card already list them. */
hint: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
/** Index into `thresholds` of the band the current value falls into.
* Renders that chip with the .active class — full opacity + highlight. */
activeIndex?: number
/** URL of the canonical chart page (usually CoinGlass). One button only —
* the dual "Data source + Chart" pattern confused users about which to click. */
chartHref: string
chartLabel?: string
/** Horizontal headline layout for a single full-width card (Valuation).
* The stacked layout stretched across the panel left a dead middle. */
hero?: boolean
}) {
const head = (
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
<span>{label}</span>
<InfoTip text={hint} placement="top" width={280} />
</div>
<div className="macro-metric-value">{value}</div>
</div>
)
const chips = (
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
{item.label}
</ThresholdChip>
))}
</div>
)
const action = (
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
)
if (hero) {
return (
<article className={`macro-metric-card tone-${tone} hero`}>
{head}
<div className="macro-hero-body">
<div className="macro-summary">{summary}</div>
{chips}
</div>
{action}
</article>
)
}
return (
<article className={`macro-metric-card tone-${tone}`}>
{head}
<div className="macro-summary">{summary}</div>
{chips}
{action}
</article>
)
}
function GuideCard({
title,
summary,
thresholds,
chartHref,
chartLabel = 'CoinGlass',
}: {
title: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
chartHref: string
chartLabel?: string
}) {
return (
<article className="macro-guide-card">
<div className="macro-guide-title">{title}</div>
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item) => (
<ThresholdChip key={item.label} tone={item.tone}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
</article>
)
}
function Section({
title,
children,
variant,
}: {
title: string
children: ReactNode
/** "reference" tones the section down + adds a 📐 icon so users see at a
* glance that the cards inside are explainers, not live data. */
variant?: 'reference'
}) {
return (
<section className={`macro-section${variant ? ' ' + variant : ''}`}>
<div className="macro-section-title">{title}</div>
{children}
</section>
)
}
export default function MacroPanel() {
const [snap, setSnap] = useState<MacroSnapshot | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
function load() {
swrFetch(
'macro-snapshot',
10 * 60_000,
() => getMacroSnapshot(),
(fresh) => {
if (alive) setSnap(fresh)
},
)
.then((s) => {
if (alive) {
setSnap(s)
setErr('')
}
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : 'load failed')
})
}
load()
const id = setInterval(load, 10 * 60_000)
return () => {
alive = false
clearInterval(id)
}
}, [])
if (err) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--down)' }}>
Couldn&apos;t load macro indicators — {err}
</div>
)
}
if (!snap) {
return (
<div className="skeleton-card">
<div className="skeleton sk-line sk-w-3q" />
<div className="skeleton sk-line sk-w-half" />
<div className="skeleton sk-line sk-w-full" />
</div>
)
}
if (!snap.ok || !snap.indicators) {
return (
<div className="card" style={{ padding: 14, fontSize: 12, color: 'var(--ink-3)' }}>
Macro snapshot not available yet — {snap.error ?? 'cron has not run'}.
</div>
)
}
const ind = snap.indicators
const ahrSay =
ind.ahr999 == null
? 'No data yet.'
: ind.ahr999 < 0.45
? 'Deep-value zone. Historically this is where macro accumulation starts to make sense.'
: ind.ahr999 > 1.2
? 'Expensive regime. This is no longer a bottom setup.'
: ind.ahr999 < 0.75
? 'Reasonable value. Closer to cheap than to overheated.'
: 'Neutral-to-rich. No strong valuation edge from this metric alone.'
const altSay =
ind.altcoin_season_index == null
? 'No data yet.'
: ind.altcoin_season_index >= 75
? 'Altseason conditions. Broad risk appetite is favoring alts over BTC.'
: ind.altcoin_season_index >= 60
? 'Alt strength is building, but it is not a full altseason yet.'
: ind.altcoin_season_index >= 40
? 'Mixed tape. Neither BTC nor alts have a decisive lead.'
: ind.altcoin_season_index >= 25
? 'BTC is leading. This is a more defensive market posture.'
: 'Bitcoin season. Alts are broadly lagging BTC.'
const fgSay =
ind.fear_greed == null
? 'No data yet.'
: ind.fear_greed <= 20
? 'Extreme fear. Contrarian bottom-hunting conditions.'
: ind.fear_greed <= 40
? 'Fear is present, but not yet a full washout.'
: ind.fear_greed <= 60
? 'Neutral sentiment. Little edge from crowd psychology here.'
: ind.fear_greed <= 80
? 'Greed is building. Be selective about adding exposure.'
: 'Extreme greed. Historically a poor place to chase upside.'
const domSay =
ind.btc_dominance_pct == null
? 'No data yet.'
: ind.btc_dominance_pct >= 65
? 'Capital is crowding back into BTC. That usually reads as caution.'
: ind.btc_dominance_pct >= 55
? 'BTC still leads the market. Risk appetite is present, but selective.'
: ind.btc_dominance_pct >= 45
? 'Balanced regime. No strong breadth message from dominance.'
: ind.btc_dominance_pct >= 35
? 'Alt participation is improving. Risk appetite is broadening.'
: 'Dominance is very low. That often shows late-cycle froth.'
const ebrSay =
ind.eth_btc_ratio == null
? 'No data yet.'
: ind.eth_btc_ratio >= 0.05
? 'ETH leadership is strong. This usually maps to a cleaner risk-on regime.'
: ind.eth_btc_ratio >= 0.04
? 'ETH is waking up versus BTC. Useful confirmation for broader crypto risk appetite.'
: ind.eth_btc_ratio >= 0.03
? 'Range-bound. ETH/BTC is not giving a decisive leadership signal.'
: ind.eth_btc_ratio >= 0.025
? 'ETH is lagging, which usually means BTC remains the safer bid.'
: 'ETH is weak versus BTC. This is a defensive backdrop.'
const stabSay =
ind.stablecoin_supply_usd == null
? 'No data yet.'
: 'This is a liquidity gauge, not a hard trigger. Watch the direction of supply over time.'
const etfSay =
ind.etf_flow_net_usd_1d == null
? 'No data yet.'
: (() => {
const v = ind.etf_flow_net_usd_1d ?? 0
if (v >= 500_000_000) return 'Huge ETF inflow day. Institutions are pressing the bid.'
if (v >= 200_000_000)
return 'Strong inflow day. Institutional demand is clearly supportive.'
if (v >= 50_000_000) return 'Positive day, but not a major outlier.'
if (v > -50_000_000) return 'Roughly flat. No strong institutional message.'
if (v > -200_000_000) return 'Moderate outflow day. Some distribution, but not panic.'
if (v > -500_000_000) return 'Heavy outflows. Institutions are leaning defensive.'
return 'Very large outflows. This is a meaningful risk-off signal.'
})()
const oiSay =
ind.btc_open_interest_usd == null
? 'No data yet.'
: 'Use this with price, not by itself. Rising OI confirms commitment; falling OI shows deleveraging.'
// ── Active threshold band per indicator ─────────────────────────────────
// Each function returns the index into the indicator's `thresholds` array
// that the CURRENT value falls into. Renders that chip with the .active
// CSS class so it stands out from the muted siblings.
// Indexes MUST stay in lock-step with the threshold array order below.
const ahrActive =
ind.ahr999 == null
? -1
: ind.ahr999 < 0.45
? 0
: ind.ahr999 < 0.75
? 1
: ind.ahr999 <= 1.2
? 2
: 3
const altActive =
ind.altcoin_season_index == null
? -1
: ind.altcoin_season_index < 25
? 0
: ind.altcoin_season_index < 60
? 1
: ind.altcoin_season_index < 75
? 2
: 3
const fgActive =
ind.fear_greed == null
? -1
: ind.fear_greed < 25
? 0
: ind.fear_greed < 45
? 1
: ind.fear_greed < 60
? 2
: ind.fear_greed < 75
? 3
: 4
const domActive =
ind.btc_dominance_pct == null
? -1
: ind.btc_dominance_pct < 45
? 0
: ind.btc_dominance_pct < 55
? 1
: ind.btc_dominance_pct < 65
? 2
: 3
const ebrActive =
ind.eth_btc_ratio == null
? -1
: ind.eth_btc_ratio < 0.025
? 0
: ind.eth_btc_ratio < 0.04
? 1
: 2
const etfActive =
ind.etf_flow_net_usd_1d == null
? -1
: ind.etf_flow_net_usd_1d > 200_000_000
? 0
: ind.etf_flow_net_usd_1d < -200_000_000
? 2
: 1
const stabActive = ind.stablecoin_supply_usd == null ? -1 : 1 // we can't infer trend → land mid
const compositeColor =
snap.composite_score == null
? 'var(--ink-3)'
: snap.composite_score > 3
? 'var(--up)'
: snap.composite_score < -3
? 'var(--down)'
: 'var(--ink-3)'
const compositePct =
snap.composite_score == null ? 50 : Math.max(0, Math.min(100, (snap.composite_score + 100) / 2))
const trackGradient =
'linear-gradient(to right, ' +
'color-mix(in oklab, var(--down) 70%, transparent) 0%, ' +
'color-mix(in oklab, var(--down) 25%, transparent) 35%, ' +
'var(--bg-sunk) 50%, ' +
'color-mix(in oklab, var(--up) 25%, transparent) 65%, ' +
'color-mix(in oklab, var(--up) 70%, transparent) 100%)'
return (
<div className="card macro-panel">
<div className="macro-panel-head">
<div>
<div className="macro-panel-title">
<span className="macro-panel-dot" />
Macro indicators
</div>
<div className="macro-panel-subtitle">
Daily snapshot for BTC macro context. Designed for scanning first, details second.
</div>
</div>
<div className="macro-panel-meta">
<div>{snap.date}</div>
<div>free public sources</div>
</div>
</div>
<Section title="Valuation">
<div className="macro-grid one">
<MetricCard
hero
rank={1}
label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. Lower = cheaper."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
{ label: '< 0.45 buy zone', tone: 'up' },
{ label: '0.45–0.75 fair value', tone: 'neutral' },
{ label: '0.75–1.2 no edge', tone: 'neutral' },
{ label: '> 1.2 expensive', tone: 'down' },
]}
chartHref="https://www.coinglass.com/en/pro/i/ahr999"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Bottom trigger reference" variant="reference">
<div className="macro-grid two">
<GuideCard
title="200-week Moving Average"
summary="A structural support check. In this system, price near the 200WMA is one of the three bottom votes."
thresholds={[
{ label: '≤ 1.05× 200WMA = vote ON', tone: 'up' },
{ label: 'Below 200WMA = strongest value zone', tone: 'up' },
{ label: '1.05×–1.20× above = recovering', tone: 'neutral' },
{ label: 'Far above = no bottom vote', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/200WMA"
chartLabel="CoinGlass"
/>
<GuideCard
title="Pi Cycle Bottom"
summary="A binary bottom condition. It is either confirmed or not confirmed; there is no graded middle reading in the live code."
thresholds={[
{ label: '150EMA ≤ 471SMA × 0.745 = vote ON', tone: 'up' },
{ label: '150EMA above line = not confirmed', tone: 'down' },
]}
// CoinGlass only hosts Pi Cycle TOP; for Pi Cycle BOTTOM the
// canonical chart lives on LookIntoBitcoin.
chartHref="https://www.lookintobitcoin.com/charts/pi-cycle-bottom-indicator/"
chartLabel="LookIntoBitcoin"
/>
</div>
</Section>
<Section title="Market structure">
<div className="macro-grid two">
<MetricCard
rank={2}
label="Altcoin Season"
value={
ind.altcoin_season_index == null
? '—'
: ind.altcoin_season_index.toFixed(0) + ' / 100'
}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). Higher = broader alt strength."
summary={altSay}
activeIndex={altActive}
thresholds={[
{ label: '< 25 Bitcoin season', tone: 'down' },
{ label: '25–60 mixed', tone: 'neutral' },
{ label: '60–75 alt strength', tone: 'up' },
{ label: '≥ 75 altseason', tone: 'up' },
]}
chartHref="https://www.blockchaincenter.net/altcoin-season-index/"
chartLabel="BlockchainCenter"
/>
<MetricCard
rank={3}
label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. High dominance = defensive, capital hiding in BTC; low = alt rotation."
summary={domSay}
activeIndex={domActive}
thresholds={[
{ label: '< 45% alt rotation', tone: 'up' },
{ label: '45–55% balanced', tone: 'neutral' },
{ label: '55–65% BTC-led', tone: 'neutral' },
{ label: '> 65% safety trade', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/bitcoin-dominance"
chartLabel="CoinGlass"
/>
<MetricCard
rank={4}
label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC — a proxy for broader crypto risk appetite."
summary={ebrSay}
activeIndex={ebrActive}
thresholds={[
{ label: '< 0.025 defensive', tone: 'down' },
{ label: '0.025–0.04 range', tone: 'neutral' },
{ label: '> 0.04 ETH leadership', tone: 'up' },
]}
chartHref="https://www.coinglass.com/currencies/ETHBTC"
chartLabel="CoinGlass"
/>
</div>
</Section>
<Section title="Sentiment & flows">
<div className="macro-grid two">
<MetricCard
rank={5}
label="Fear & Greed"
value={ind.fear_greed == null ? '—' : String(ind.fear_greed)}
tone={toneFearGreed(ind.fear_greed)}
hint="Sentiment composite from alternative.me. CONTRARIAN read — extreme fear is bullish, extreme greed is bearish."
summary={fgSay}
activeIndex={fgActive}
thresholds={[
{ label: '< 25 → BUY (extreme fear)', tone: 'up' },
{ label: '25–45 fear', tone: 'neutral' },
{ label: '45–60 neutral', tone: 'neutral' },
{ label: '60–75 greed', tone: 'neutral' },
{ label: '> 75 → SELL (extreme greed)', tone: 'down' },
]}
chartHref="https://www.coinglass.com/pro/i/FearGreedIndex?s=09"
chartLabel="CoinGlass"
/>
<MetricCard
rank={6}
label="Stablecoin Supply"
value={
ind.stablecoin_supply_usd == null
? '—'
: '$' + fmtUsd(ind.stablecoin_supply_usd, { compact: true })
}
summary={stabSay}
hint="Sum of all USDT + USDC + DAI + others in circulation. Rising = dry powder accumulating; falling = capital exiting crypto."
activeIndex={stabActive}
thresholds={[
{ label: 'Rising = liquidity building', tone: 'up' },
{ label: 'Flat = neutral', tone: 'neutral' },
{ label: 'Falling = capital leaving', tone: 'down' },
]}
// CoinGlass doesn't expose an all-issuer stablecoin supply view
// cleanly; DeFiLlama is the canonical aggregator we pull from.
chartHref="https://defillama.com/stablecoins"
chartLabel="DeFiLlama"
/>
<MetricCard
rank={7}
label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs — a direct read on institutional demand."
summary={etfSay}
activeIndex={etfActive}
thresholds={[
{ label: '> +$200M strong bid', tone: 'up' },
{ label: '±$50M noise band', tone: 'neutral' },
{ label: '< −$200M heavy outflow', tone: 'down' },
]}
chartHref="https://www.coinglass.com/etf"
/>
<MetricCard
rank={8}
label="BTC Open Interest"
value={
ind.btc_open_interest_usd == null
? '—'
: '$' + fmtUsd(ind.btc_open_interest_usd, { compact: true })
}
summary={oiSay}
hint="Total notional in BTC futures. No universal absolute threshold: use OI with price. Rising OI + rising price = trend confirmation; rising OI + falling price = trapped longs."
thresholds={[
{ label: 'OI up + price up = confirmation', tone: 'up' },
{ label: 'OI up + price down = stress', tone: 'down' },
{ label: 'OI down after flush = reset', tone: 'neutral' },
]}
chartHref="https://www.tradingview.com/symbols/BTC_OI/"
chartLabel="TradingView"
/>
</div>
</Section>
<div className="macro-composite">
<div className="macro-composite-head">
<div>
<div className="macro-composite-label">Composite read</div>
<div className="macro-panel-subtitle">
Advisory blend of the metrics above. Useful for context, not for blind execution.
</div>
</div>
<div className="macro-composite-right">
<span className="macro-composite-value" style={{ color: compositeColor }}>
{snap.composite_score == null
? '—'
: (snap.composite_score > 0 ? '+' : '') + snap.composite_score.toFixed(1)}
</span>
{snap.regime_label ? (
<span
className="macro-regime-pill"
style={{
color: REGIME_COLOR[snap.regime_label] || 'var(--ink-3)',
borderColor: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 32%, transparent)`,
background: `color-mix(in oklab, ${REGIME_COLOR[snap.regime_label] || 'var(--ink-3)'} 12%, transparent)`,
}}
>
{snap.regime_label}
</span>
) : null}
</div>
</div>
<div className="macro-composite-track" style={{ background: trackGradient }}>
{snap.composite_score != null ? (
<div
// Keying by score forces a remount → the @keyframes pulse on
// .macro-composite-needle re-fires every time the score moves.
key={snap.composite_score}
className="macro-composite-needle"
style={{
left: `calc(${compositePct}% - 10px)`,
borderColor: compositeColor,
color: compositeColor,
}}
/>
) : null}
</div>
<div className="macro-composite-scale">
{/* .word spans are hidden on screens ≤420px so the scale becomes
just "-100", "0", "+100" — fits comfortably on any phone. */}
<span>
−100 <span className="word">bear</span>
</span>
<span>
0 <span className="word">neutral</span>
</span>
<span>
+100 <span className="word">bull</span>
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,522 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
interface ChartPanelProps {
posts?: TrumpPost[]
candles?: Candle[]
externalSelectedId?: number | null
onSelectPost?: (id: number | null) => void
onSelectDayPosts?: (posts: TrumpPost[]) => void
}
export default function ChartPanel({
posts = [],
candles = [],
externalSelectedId,
onSelectPost,
onSelectDayPosts,
}: ChartPanelProps) {
const { timeframe, asset, livePrices } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null)
const seriesRef = useRef<unknown>(null)
const macroPriceLineRef = useRef<unknown>(null)
const macroVerticalRef = useRef<HTMLDivElement | null>(null)
const macroLabelRef = useRef<HTMLDivElement | null>(null)
// Macro overlay state. The vertical line + label are absolutely-positioned
// DOM nodes (lightweight-charts has no first-class "vertical marker"
// primitive), so we have to manually re-project signal time → pixel X
// every time the visible range / chart width changes. Without this the
// line stays anchored to the SCREEN while the candles pan beneath it,
// which looks exactly like a draggable / floating line.
const macroBucketTimeRef = useRef<number | null>(null)
const macroLabelTextRef = useRef<string>('')
// Imperative reposition fn — set up by the chart-creation effect, called
// from BOTH the data effect (when the signal changes) AND from a
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
const repositionMacroRef = useRef<(() => void) | null>(null)
const fittedRef = useRef(false)
// Chart creation is async (dynamic import). If the candles fetch wins the
// race (common — SWR serves them instantly from cache), the data effect
// runs while seriesRef is still null and bails; nothing re-triggers it
// afterwards, so the chart stays EMPTY and the 2s live-tick effect then
// paints a single lone candle via series.update(). This state flips when
// the chart actually exists so the data effect re-runs.
const [chartReady, setChartReady] = useState(false)
// The live-tick effect must never run before setData() — updating an empty
// series creates a one-candle chart.
const dataLoadedRef = useRef(false)
const postsRef = useRef(posts)
postsRef.current = posts
const selectedPostIdRef = useRef(externalSelectedId)
selectedPostIdRef.current = externalSelectedId
const timeframeRef = useRef(timeframe)
timeframeRef.current = timeframe
const onSelectRef = useRef(onSelectPost)
onSelectRef.current = onSelectPost
const onSelectDayPostsRef = useRef(onSelectDayPosts)
onSelectDayPostsRef.current = onSelectDayPosts
// Detect current theme for chart colors
function getChartColors() {
const isDark = document.documentElement.dataset.theme === 'dark'
return {
background: isDark ? '#121212' : '#ffffff',
textColor: isDark ? '#666666' : '#888888',
gridColor: isDark ? '#1e1e1e' : '#f0ede8',
borderColor: isDark ? '#2a2a2a' : '#e8e4de',
macroAccent: isDark ? '#f59e0b' : '#b45309',
macroAccentSoft: isDark ? 'rgba(245,158,11,0.24)' : 'rgba(180,83,9,0.18)',
}
}
// Create chart once on mount
useEffect(() => {
if (!containerRef.current || typeof window === 'undefined') return
let destroyed = false
let ro: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
import('lightweight-charts').then(({ createChart, CrosshairMode }) => {
if (destroyed || !containerRef.current) return
const colors = getChartColors()
const vertical = document.createElement('div')
vertical.style.position = 'absolute'
vertical.style.top = '0'
vertical.style.bottom = '0'
vertical.style.width = '0'
vertical.style.borderLeft = `2px dashed ${colors.macroAccent}`
vertical.style.opacity = '0'
vertical.style.pointerEvents = 'none'
vertical.style.zIndex = '2'
// NO transition on `left` — the reposition callback fires on every
// pan/zoom frame; a 160ms ease there makes the line lag visibly
// behind the candles and looks exactly like the original bug.
vertical.style.transition = 'opacity 120ms ease'
containerRef.current.appendChild(vertical)
macroVerticalRef.current = vertical
const label = document.createElement('div')
label.style.position = 'absolute'
label.style.top = '14px'
label.style.left = '0'
label.style.padding = '5px 8px'
label.style.borderRadius = '999px'
label.style.border = `1px solid ${colors.macroAccentSoft}`
label.style.background = colors.background
label.style.boxShadow = '0 6px 24px rgba(15, 23, 42, 0.08)'
label.style.color = colors.macroAccent
label.style.fontSize = '11px'
label.style.fontWeight = '700'
label.style.letterSpacing = '0.04em'
label.style.textTransform = 'uppercase'
label.style.whiteSpace = 'nowrap'
label.style.pointerEvents = 'none'
label.style.opacity = '0'
label.style.transform = 'translateX(-50%)'
label.style.zIndex = '3'
// Same reasoning as the vertical line: no transition on `left` —
// panning must keep the label glued to its timestamp without lag.
label.style.transition = 'opacity 120ms ease'
containerRef.current.appendChild(label)
macroLabelRef.current = label
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
// Follow the container, don't hardcode: .chart-wrap is 360px on
// desktop but 300/240px behind !important media queries. A fixed
// 360 overflows those and overflow:hidden cuts off the bottom of
// the chart — lowest candles, markers, and the whole time axis.
height: containerRef.current.clientHeight || 360,
layout: {
background: { color: colors.background },
textColor: colors.textColor,
},
grid: {
vertLines: { color: colors.gridColor },
horzLines: { color: colors.gridColor },
},
crosshair: { mode: CrosshairMode.Normal },
rightPriceScale: { borderColor: colors.borderColor },
timeScale: {
borderColor: colors.borderColor,
timeVisible: true,
rightOffset: 5,
barSpacing: 10,
},
handleScroll: true,
handleScale: true,
})
chartRef.current = chart
const series = chart.addCandlestickSeries({
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
seriesRef.current = series
// Breathing room above/below the candles. Markers render ~10px outside
// the bar's high/low; with the default tight bottom margin a crash
// candle at the bottom of the autoscaled range gets its marker dots
// clipped by the pane edge.
series.priceScale().applyOptions({
scaleMargins: { top: 0.12, bottom: 0.16 },
})
setChartReady(true)
// Re-project macro signal time → pixel X on every visible-range
// change (pan / zoom / data update / resize). Without this the line
// is set ONCE in the data effect and then stays stuck at a fixed
// pixel column while the user scrolls the chart — the bug the user
// reported as "this line is draggable".
const reposition = () => {
const c = chartRef.current as any
const vert = macroVerticalRef.current
const lbl = macroLabelRef.current
const container = containerRef.current
if (!c || !vert || !container) return
const bucket = macroBucketTimeRef.current
if (bucket == null) {
vert.style.opacity = '0'
if (lbl) lbl.style.opacity = '0'
return
}
const x = c.timeScale?.().timeToCoordinate?.(bucket)
const w = container.clientWidth
// Hide (don't clamp) when the signal time is OUTSIDE the visible
// range. Clamping to the edge would put the "MACRO BOTTOM" label
// on candles that have nothing to do with the signal — actively
// misleading.
if (typeof x !== 'number' || !Number.isFinite(x) || x < 0 || x > w) {
vert.style.opacity = '0'
if (lbl) lbl.style.opacity = '0'
return
}
vert.style.left = `${x}px`
vert.style.opacity = '1'
if (lbl) {
lbl.textContent = macroLabelTextRef.current
const clamped = Math.max(76, Math.min(w - 76, x))
lbl.style.left = `${clamped}px`
lbl.style.opacity = '1'
}
}
repositionMacroRef.current = reposition
// lightweight-charts fires this on user pan, wheel zoom, programmatic
// setVisibleRange, and on each new candle. We piggy-back on it for
// every reposition trigger we care about.
chart.timeScale().subscribeVisibleTimeRangeChange(reposition)
chart.subscribeClick((param: any) => {
if (!param.time) return
const clickTime = typeof param.time === 'number' ? param.time : 0
if (!clickTime) return
const bucketByTf: Record<string, number> = {
'5m': 300,
'15m': 900,
'1h': 3600,
'4h': 14400,
'1d': 86400,
'1w': 604800,
}
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? 3600
const clickBucket = Math.floor(clickTime / bucketSecs) * bucketSecs
const inBucket = postsRef.current.filter((p) => {
if (!p.published_at) return false
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
return Math.floor(pt / bucketSecs) * bucketSecs === clickBucket
})
if (inBucket.length === 0) {
onSelectRef.current?.(null)
return
}
// Multiple posts in this candle bucket → show all of them in the right rail
if (inBucket.length > 1 && onSelectDayPostsRef.current) {
const sorted = [...inBucket].sort(
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime(),
)
onSelectDayPostsRef.current(sorted)
return
}
// Single post → show detail directly
onSelectRef.current?.(inBucket[0].id)
})
ro = new ResizeObserver(() => {
if (containerRef.current && !destroyed) {
chart.applyOptions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight || 360,
})
// Width change → new pixel coordinates → re-project the macro
// marker too, otherwise it'd drift relative to the candles on
// window resize / sidebar toggle.
repositionMacroRef.current?.()
}
})
ro.observe(containerRef.current)
themeObserver = new MutationObserver(() => {
const colors = getChartColors()
chart.applyOptions({
layout: {
background: { color: colors.background },
textColor: colors.textColor,
},
grid: {
vertLines: { color: colors.gridColor },
horzLines: { color: colors.gridColor },
},
rightPriceScale: { borderColor: colors.borderColor },
timeScale: { borderColor: colors.borderColor },
})
if (macroVerticalRef.current) {
macroVerticalRef.current.style.borderLeft = `2px dashed ${colors.macroAccent}`
}
if (macroLabelRef.current) {
macroLabelRef.current.style.color = colors.macroAccent
macroLabelRef.current.style.borderColor = colors.macroAccentSoft
macroLabelRef.current.style.background = colors.background
}
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
})
})
return () => {
destroyed = true
ro?.disconnect()
themeObserver?.disconnect()
fittedRef.current = false
dataLoadedRef.current = false
setChartReady(false)
if (chartRef.current) {
// @ts-expect-error lightweight-charts type
chartRef.current.remove()
chartRef.current = null
seriesRef.current = null
}
if (macroVerticalRef.current?.parentNode) {
macroVerticalRef.current.parentNode.removeChild(macroVerticalRef.current)
}
if (macroLabelRef.current?.parentNode) {
macroLabelRef.current.parentNode.removeChild(macroLabelRef.current)
}
macroVerticalRef.current = null
macroLabelRef.current = null
macroPriceLineRef.current = null
}
}, [])
// Update candles + markers
useEffect(() => {
const series = seriesRef.current
const chart = chartRef.current
if (!series || !chart || candles.length === 0) {
dataLoadedRef.current = false
return
}
const sorted = [...candles].sort((a, b) => a.time - b.time)
// @ts-expect-error lightweight-charts type
series.setData(
sorted.map((c) => ({
time: c.time as number,
open: c.open,
high: c.high,
low: c.low,
close: c.close,
})),
)
dataLoadedRef.current = true
const minTime = sorted[0].time
const maxTime = sorted[sorted.length - 1].time
const visible = posts.filter((p) => {
if (!p.published_at) return false
const t = Math.floor(new Date(p.published_at).getTime() / 1000)
return t >= minTime && t <= maxTime
})
// Markers show actionable signals only. Hold/filtered posts vastly
// outnumber signals and their gray dots pile into unreadable clusters;
// they stay browsable in the Recent-signals list instead.
const markerPosts = visible.filter(
(p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell',
)
const bucketByTf: Record<string, number> = {
'5m': 300,
'15m': 900,
'1h': 3600,
'4h': 14400,
'1d': 86400,
'1w': 604800,
}
const candleSpacing = sorted.length > 1 ? sorted[1].time - sorted[0].time : 300
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>()
for (const p of markerPosts) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
bucketMap.get(bucket)!.push(p)
}
bucketMap.forEach((ps) => ps.sort((a, b) => (b.ai_confidence ?? 0) - (a.ai_confidence ?? 0)))
const markers = Array.from(bucketMap.entries())
.sort(([a], [b]) => a - b)
.map(([bucketTime, bPosts]) => {
const isSelected = bPosts.some((p) => p.id === externalSelectedId)
const best = bPosts[0]
const count = bPosts.length
const signalColor = isSelected
? '#f59e0b'
: best.signal === 'short' || best.signal === 'sell'
? '#ef5350'
: best.signal === 'buy'
? '#26a69a'
: '#aaaaaa'
return {
time: bucketTime as number,
position: 'aboveBar' as const,
color: signalColor,
shape: 'circle' as const,
text: count > 1 ? String(count) : '',
size: isSelected ? 2 : count > 1 ? 1.5 : 1,
}
})
// @ts-expect-error lightweight-charts type
series.setMarkers(markers)
// Highlight the most recent visible Macro Vibes reversal signal on BTC.
const visibleMacro =
asset === 'BTC'
? visible
.filter(
(p) =>
((p.source || '') === 'btc_bottom_reversal' ||
(p.source || '') === 'funding_reversal') &&
(p.signal === 'buy' || p.signal === 'short'),
)
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
: []
const macroSignal = visibleMacro[0] ?? null
const colors = getChartColors()
const priceSeries = series as any
if (macroPriceLineRef.current && priceSeries?.removePriceLine) {
try {
priceSeries.removePriceLine(macroPriceLineRef.current as any)
} catch {}
macroPriceLineRef.current = null
}
if (macroSignal && chart && macroVerticalRef.current) {
const pt = Math.floor(new Date(macroSignal.published_at).getTime() / 1000)
const bucketTime = Math.floor(pt / bucketSecs) * bucketSecs
// Only draw the horizontal reference when we have a true signal-entry
// price from the backend. Falling back to a bucket close made the line
// drift across timeframe changes and looked like a bug because it was.
const priceAtSignal =
macroSignal.price_impact?.asset === 'BTC' && macroSignal.price_impact?.price_at_post
? macroSignal.price_impact.price_at_post
: null
if (priceAtSignal != null && priceSeries?.createPriceLine) {
macroPriceLineRef.current = priceSeries.createPriceLine({
price: priceAtSignal,
color: colors.macroAccent,
lineWidth: 2,
lineStyle: 2,
axisLabelVisible: false,
title:
(macroSignal.source || '') === 'btc_bottom_reversal'
? 'Macro bottom'
: 'Funding reversal',
})
}
// Hand off positioning to the shared reposition fn — it knows how
// to re-project on every pan/zoom too, not just on data update.
// (Before this fix, the line was positioned ONCE here and never
// moved with the chart; it looked like a draggable floating element
// because the candles slid out from under it.)
macroBucketTimeRef.current = bucketTime
macroLabelTextRef.current =
(macroSignal.source || '') === 'btc_bottom_reversal' ? 'Macro bottom' : 'Funding reversal'
repositionMacroRef.current?.()
} else {
macroBucketTimeRef.current = null
repositionMacroRef.current?.()
}
if (!fittedRef.current) {
// @ts-expect-error lightweight-charts type
chart.timeScale().fitContent()
fittedRef.current = true
}
}, [candles, posts, externalSelectedId, asset, chartReady])
useEffect(() => {
fittedRef.current = false
}, [timeframe, asset])
// Live-tick the rightmost candle so the chart feels alive between REST polls.
// lightweight-charts' `series.update()` either appends a new bar (newer time)
// or in-place mutates the bar at that timestamp. We always keep `time` ==
// the last candle's bucket so the bar grows in place; high/low expand if
// the live tick exceeds them.
useEffect(() => {
const series = seriesRef.current as any
// dataLoadedRef: update() on a series that hasn't had setData() yet
// creates a chart with a single lone candle.
if (!series || !dataLoadedRef.current) return
const live = livePrices[asset]
if (live == null || !candles.length) return
const last = candles[candles.length - 1]
series.update({
time: last.time as number,
open: last.open,
high: Math.max(last.high, live),
low: Math.min(last.low, live),
close: live,
})
}, [livePrices, asset, candles])
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
borderRadius: 'var(--r-sm)',
overflow: 'hidden',
cursor: 'crosshair',
position: 'relative',
}}
/>
)
}
+418
View File
@@ -0,0 +1,418 @@
'use client'
import { memo, useEffect, useState } from 'react'
import type { TrumpPost } from '@/types'
/**
* Was this post actually AI-scored, or filtered as off-topic noise before any
* AI call? Single source of truth — used both by the card (to de-emphasise
* noise) and by list views (to offer a "collapse off-topic" toggle), so the
* two can never disagree about what counts as noise.
*/
export function isAiScored(post: Pick<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
}
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—' // null = window not yet closed
const s = n.toFixed(2) + '%'
return n >= 0 ? '+' + s : s
}
function fmtImpactPct(v: number | null | undefined) {
if (v == null || isNaN(v)) return '—'
const s = Math.abs(v).toFixed(2) + '%'
return v >= 0 ? '+' + s : '-' + s
}
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
if (m < 1) return 'just now'
if (m < 60) return m + 'm'
const h = Math.floor(m / 60)
if (h < 24) return h + 'h'
return Math.floor(h / 24) + 'd'
}
/**
* Hydration-safe wrapper for relative time. Returns an empty placeholder on
* SSR / first client render, then the real relative time after mount. Prevents
* the SSR/CSR "5m vs 6m" mismatch error.
*/
function TimeAgo({ iso, suffix = '' }: { iso: string; suffix?: string }) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return <span suppressHydrationWarning>…</span>
return (
<span>
{timeAgo(iso)}
{suffix}
</span>
)
}
/**
* Hydration-safe absolute date formatter. Uses fixed 'en-US' locale + options
* so the server/client outputs match once mounted; renders a placeholder before
* mount to avoid timezone mismatches.
*/
function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatOptions }) {
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) return <span suppressHydrationWarning>…</span>
return <span>{new Date(iso).toLocaleString('en-US', opts)}</span>
}
// Visual identity per signal source. The post stream now mixes Trump posts
// with scanner-emitted technical signals — operators need to tell them
// apart at a glance without reading the text.
//
// When adding a new scanner source, register it here too — otherwise it
// falls through to the generic "first letter" fallback which has no title
// or accent colour.
export const SOURCE_DISPLAY: Record<
string,
{ glyph: string; cls: string; title: string; label: string }
> = {
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social', label: '@realDonaldTrump' },
breakout: {
glyph: '▲',
cls: 'breakout',
title: 'VCP / breakout scanner',
label: 'Breakout scanner',
},
vcp_breakout: {
glyph: '▲',
cls: 'breakout',
title: 'VCP / breakout scanner',
label: 'Breakout scanner',
},
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner', label: 'Reversal scanner' },
btc_bottom_reversal: {
glyph: '₿',
cls: 'reversal',
title: 'BTC Macro Bottom scanner',
label: 'BTC Macro Bottom',
},
funding_reversal: {
glyph: 'ƒ',
cls: 'reversal',
title: 'BTC Funding Rate Reversal scanner',
label: 'Funding Reversal',
},
kol_divergence: {
glyph: '⚖',
cls: 'whale',
title: 'KOL Talks-vs-Trades Divergence',
label: 'KOL Divergence',
},
sma_reclaim: { glyph: '〽', cls: 'breakout', title: 'SMA reclaim scanner', label: 'SMA Reclaim' },
rsi_reversal: {
glyph: '⇋',
cls: 'reversal',
title: 'RSI reversal scanner',
label: 'RSI Reversal',
},
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert', label: 'Whale alert' },
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry', label: 'Manual' },
}
function SourceIcon({ source }: { source: string }) {
const d = SOURCE_DISPLAY[source.toLowerCase()]
if (d) {
return (
<div className={`src-ico ${d.cls}`} title={d.title}>
{d.glyph}
</div>
)
}
// Unknown source — show first letter so user can still tell it apart.
return (
<div className="src-ico external" title={`Source: ${source}`}>
{source.charAt(0).toUpperCase()}
</div>
)
}
function SignalPill({ signal }: { signal: string | null }) {
if (!signal || signal === 'hold') return <span className="sig hold">HOLD</span>
if (signal === 'buy') return <span className="sig buy">BUY</span>
if (signal === 'short') return <span className="sig short">SHORT</span>
if (signal === 'sell') return <span className="sig sell">SELL</span>
return <span className={`sig ${signal}`}>{signal.toUpperCase()}</span>
}
interface PostRowProps {
post: TrumpPost
selected?: boolean
onClick?: () => void
}
const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps) {
const [expanded, setExpanded] = useState(false)
const impact = post.price_impact
function handleClick() {
if (!onClick) setExpanded((e) => !e)
onClick?.()
}
// Off-topic Trump posts (most of them) are filtered before any AI call, so
// they carry confidence 0 + no reasoning. Treat those as un-scored noise and
// de-emphasise them. Shared helper so list-level "collapse off-topic" agrees.
const aiScored = isAiScored(post)
return (
<div
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'} ${post.signal === 'buy' ? 'signal-buy' : post.signal === 'short' ? 'signal-short' : ''}`}
onClick={handleClick}
>
{/* ── main row ── */}
<div className="post-row-main">
<SourceIcon source={post.source} />
<div className="post-body">
<div className="meta">
{/* Author label depends on source — non-Trump signals come from
technical scanners or external modules, not @realDonaldTrump. */}
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
</span>
<span>·</span>
<TimeAgo iso={post.published_at} suffix=" ago" />
<span>·</span>
{aiScored ? (
<span
className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}
style={{ padding: '2px 8px', fontSize: 11 }}
>
{post.sentiment}
</span>
) : (
<span
style={{
padding: '2px 8px',
fontSize: 11,
fontWeight: 600,
borderRadius: 4,
color: 'var(--ink-4)',
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
letterSpacing: '0.02em',
}}
>
off-topic · not crypto
</span>
)}
</div>
<p
className="text"
style={{
...(expanded
? { display: 'block', overflow: 'visible', WebkitLineClamp: 'unset' }
: {}),
// Noise posts are de-emphasised: dimmer text + shorter preview so
// the reader can skip them at a glance without opening.
...(aiScored ? {} : { color: 'var(--ink-4)' }),
}}
>
{expanded
? post.text
: post.text.slice(0, aiScored ? 180 : 90) +
(post.text.length > (aiScored ? 180 : 90) ? '…' : '')}
</p>
</div>
{/* Aside (signal pill / target / impact / AI%) only for scored posts.
Noise posts render as a compact two-column row (no aside) — the
'off-topic · not crypto' tag in the meta line is enough. */}
{aiScored && (
<div className="post-aside">
<SignalPill signal={post.signal} />
{/* Target asset chip for actionable signals */}
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
<span
style={{
fontSize: 10,
fontWeight: 700,
fontFamily: 'var(--mono)',
padding: '2px 6px',
borderRadius: 4,
background:
post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
letterSpacing: '0.04em',
}}
>
{post.target_asset}
{post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''}
</span>
)}
<div className="impact-mini">
{impact ? (
<>
<span className="tf">1h move</span>
<span
className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}
>
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
</span>
</>
) : (
<span className="tf">no data</span>
)}
</div>
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
<span>AI</span>
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
{post.ai_confidence}%
</span>
</div>
</div>
)}
</div>
{/* ── expanded detail ── */}
{expanded && (
<div className="post-row-detail" onClick={(e) => e.stopPropagation()}>
{/* AI confidence bar — only when the post was actually AI-scored.
Off-topic/noise posts (relevant=false, conf 0, no reasoning) show
a neutral note instead of a misleading empty 0% bar. */}
{aiScored ? (
<div>
<div className="ai-metric-head">
<span>AI confidence</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar">
<div style={{ width: post.ai_confidence + '%' }} />
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--ink-3)', padding: '4px 0' }}>
Not crypto-relevant — skipped AI scoring.
</div>
)}
{/* Trade routing — target asset + expected move */}
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
<span className="tiny">Trade</span>
<span
style={{
fontSize: 12,
fontWeight: 700,
fontFamily: 'var(--mono)',
padding: '3px 8px',
borderRadius: 5,
background:
post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
}}
>
{post.signal === 'buy'
? `↑ LONG ${post.target_asset}`
: `↓ SHORT ${post.target_asset}`}
</span>
{post.expected_move_pct != null && (
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{`model projection ~${post.expected_move_pct}% · 1h`}
</span>
)}
{post.category && (
<span
style={{
fontSize: 10,
color: 'var(--ink-4)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{post.category.replace(/_/g, ' ')}
</span>
)}
</div>
)}
{/* AI reasoning */}
{post.ai_reasoning && (
<div>
<div className="ai-reasoning-label">
{post.signal === 'buy' || post.signal === 'short'
? 'Why this signal fired'
: 'Why this was filtered out'}
</div>
<div className="ai-reasoning-card">{post.ai_reasoning}</div>
</div>
)}
{/* Price impact — peak move in signal direction per window */}
{impact && (
<div>
<div
className="tiny"
style={{ marginBottom: 8 }}
>{`Price moved · ${impact.asset} · after signal`}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{(['m5', 'm15', 'm1h'] as const).map((key) => {
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
const v = impact[key] // null = window still open
const correct =
impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h']
const pending = v == null
return (
<div
key={key}
style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: `1px solid ${pending ? 'var(--amber)' : 'var(--line)'}`,
textAlign: 'center',
opacity: pending ? 0.75 : 1,
}}
>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>
{label}
</div>
<div
className={pending ? '' : `delta ${v >= 0 ? 'up' : 'down'}`}
style={{
fontSize: 14,
fontWeight: 600,
color: pending ? 'var(--ink-3)' : undefined,
}}
>
{pending ? '…' : fmtImpactPct(v)}
</div>
{!pending && correct != null && (
<div
style={{
fontSize: 10,
marginTop: 4,
color: correct ? 'var(--up)' : 'var(--down)',
}}
>
{correct ? '✓' : '✗'}
</div>
)}
{pending && (
<div style={{ fontSize: 9, marginTop: 4, color: 'var(--amber)' }}>live</div>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)
})
export default PostRow
export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime }
@@ -0,0 +1,299 @@
'use client'
import { useState, useEffect } from 'react'
import { useWsSubscribe } from '@/lib/wsContext'
const API_BASE = '/api/proxy/api'
interface SignalAlert {
type: 'funding_signal'
symbol: string
time: string
close: number
tbr: number
vol_mult: number
bb_pct: number
bb_upper: number
btc_trend: string
enabled: boolean
}
function symbolLabel(s: string) {
return s.replace('USDT', '')
}
function timeAgo(iso: string) {
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return `${Math.round(diff)}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
return `${Math.round(diff / 3600)}h ago`
}
// ── Main component ────────────────────────────────────────────────────────────
// Display-only: toggle is operator-only (requires X-Ingest-Key) so the
// on/off switch is intentionally absent from the user-facing UI.
export default function SignalMonitor() {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [enabled, setEnabledState] = useState(false)
const [signals, setSignals] = useState<SignalAlert[]>([])
const [btcTrend, setBtcTrend] = useState<string | null>(null)
const [lastScan, setLastScan] = useState<Date | null>(null)
// ── Load initial state ───────────────────────────────────────────────────
useEffect(() => {
fetch(`${API_BASE}/signal/status`)
.then((r) => {
if (!r.ok) throw new Error()
return r.json()
})
.then((d) => {
setEnabledState(d.enabled)
})
.catch(() => {})
fetch(`${API_BASE}/signal/history?limit=20`)
.then((r) => r.json())
.then((list: SignalAlert[]) => {
if (!Array.isArray(list)) return
setSignals(list)
if (list.length > 0) {
setBtcTrend(list[0].btc_trend)
setLastScan(new Date(list[0].time))
}
})
.catch(() => {})
}, [])
// ── WebSocket listener (shared singleton connection via WsProvider) ─────────
useWsSubscribe('funding_signal', (msg) => {
const alert = msg as SignalAlert
setBtcTrend(alert.btc_trend)
setLastScan(new Date(alert.time))
setSignals((prev) => [alert, ...prev].slice(0, 50))
})
// ── Render ───────────────────────────────────────────────────────────────
const btcUp = btcTrend?.includes('↑')
return (
<div className="card" style={{ padding: 20, marginTop: 12 }}>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 14,
}}
>
<div>
<div
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
marginBottom: 3,
}}
>
{isZh ? '突破监控' : 'Breakout Monitor'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}>
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
</div>
</div>
{/* Enabled status dot — display-only; toggle is operator-only (X-Ingest-Key) */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: enabled ? '#22c55e' : 'var(--ink-3)',
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: '50%',
background: enabled ? '#22c55e' : 'var(--ink-4)',
boxShadow: enabled ? '0 0 0 2px rgba(34,197,94,0.25)' : 'none',
}}
/>
{enabled ? (isZh ? '监控中' : 'Active') : isZh ? '已暂停' : 'Paused'}
</div>
</div>
{/* Status row: BTC trend + last scan */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: 11,
color: 'var(--ink-3)',
padding: '6px 10px',
borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
marginBottom: 14,
}}
>
<span>
BTC &nbsp;
{btcTrend ? (
<strong style={{ color: btcUp ? '#22c55e' : '#ef4444' }}>{btcTrend}</strong>
) : (
<span style={{ opacity: 0.5 }}>—</span>
)}
</span>
<span>
{lastScan
? isZh
? `最近信号 ${timeAgo(lastScan.toISOString())}`
: `Last signal ${timeAgo(lastScan.toISOString())}`
: enabled
? isZh
? '扫描中…'
: 'Scanning…'
: isZh
? '已暂停'
: 'Paused'}
</span>
</div>
{/* Signal list */}
{signals.length === 0 ? (
<div
style={{
fontSize: 13,
color: 'var(--ink-3)',
textAlign: 'center',
padding: '20px 0',
lineHeight: 1.5,
}}
>
{enabled
? isZh
? '· 正在等待信号…'
: '· Watching for signals…'
: isZh
? '· 暂无历史信号'
: '· No signals yet'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{signals.map((s) => (
<div
key={`${s.symbol}-${s.time}`}
style={{
padding: '11px 13px',
borderRadius: 10,
background: 'rgba(255,255,255,0.04)',
borderLeft: `3px solid ${s.enabled ? '#22c55e' : 'rgba(255,255,255,0.15)'}`,
}}
>
{/* Top row */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span
style={{
fontWeight: 700,
fontSize: 14,
fontFamily: 'var(--mono)',
color: 'var(--ink)',
}}
>
{symbolLabel(s.symbol)}
</span>
{!s.enabled && (
<span
style={{
fontSize: 9,
padding: '2px 6px',
borderRadius: 4,
background: 'rgba(255,255,255,0.08)',
color: 'var(--ink-3)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
silent
</span>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{timeAgo(s.time)}</span>
</div>
{/* Metrics grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{[
{
label: isZh ? '价格' : 'Price',
value: `$${s.close.toLocaleString(undefined, { maximumFractionDigits: 2 })}`,
color: 'var(--ink)',
},
{
label: isZh ? '主动买入' : 'Taker Buy',
value: `${(s.tbr * 100).toFixed(1)}%`,
color: '#22c55e',
},
{
label: isZh ? '成交量倍数' : 'Vol ×',
value: `${s.vol_mult}×`,
color: '#22c55e',
},
].map((m) => (
<div
key={m.label}
style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: 6,
padding: '5px 8px',
}}
>
<div
style={{
fontSize: 9,
color: 'var(--ink-3)',
marginBottom: 2,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
{m.label}
</div>
<div
style={{
fontSize: 13,
fontFamily: 'var(--mono)',
fontWeight: 600,
color: m.color,
}}
>
{m.value}
</div>
</div>
))}
</div>
{/* Footer */}
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 8 }}>
{isZh ? `BB squeeze 处于第 ${s.bb_pct} 分位` : `BB squeeze ${s.bb_pct}th pct`}{' '}
&nbsp;·&nbsp; {s.btc_trend}
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { usePathname, useSearchParams } from '@/lib/navigation'
import { defaultLocale, locales, type Locale } from '@/i18n'
function replaceLocale(pathname: string, nextLocale: Locale) {
const segments = pathname.split('/').filter(Boolean)
const current = segments[0]
if (locales.includes(current as Locale)) {
segments[0] = nextLocale
} else {
segments.unshift(nextLocale)
}
return '/' + segments.join('/')
}
export default function LanguageSwitch() {
const pathname = usePathname()
const searchParams = useSearchParams()
const options: Array<{ locale: Locale; label: string }> = [{ locale: 'en', label: 'EN' }]
const activeLocale = useMemo<Locale>(() => {
const seg = pathname.split('/').filter(Boolean)[0]
return locales.includes(seg as Locale) ? (seg as Locale) : defaultLocale
}, [pathname])
return (
<div
aria-label="Language switch"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: 4,
borderRadius: 999,
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
flexShrink: 0,
}}
>
{options.map(({ locale, label }) => {
const active = activeLocale === locale
const nextPath = replaceLocale(pathname || `/${defaultLocale}`, locale)
const qs = searchParams.toString()
const href = qs ? `${nextPath}?${qs}` : nextPath
return (
<Link
key={locale}
href={href}
aria-pressed={active}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 40,
padding: '6px 10px',
borderRadius: 999,
border: 'none',
background: active ? 'var(--ink)' : 'transparent',
color: active ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 12,
fontWeight: 700,
lineHeight: 1,
cursor: 'pointer',
textDecoration: 'none',
}}
>
{label}
</Link>
)
})}
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from '@/lib/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { useTranslations } from 'next-intl'
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
import { needsMobileWallet } from '@/lib/mobileWallet'
import MobileWalletSheet from '@/components/wallet/MobileWalletSheet'
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
// import LanguageSwitch from './LanguageSwitch'
function BrandMark() {
return <span className="brand-mark">α</span>
}
function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => {
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
const t = stored || 'light'
setTheme(t)
document.documentElement.dataset.theme = t
}, [])
function toggle() {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
localStorage.setItem('theme', next)
document.documentElement.dataset.theme = next
}
return (
<button
className="icon-btn theme-toggle"
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
<path
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
)}
</button>
)
}
export default function Navbar() {
const t = useTranslations('nav')
const pathname = usePathname()
const { address, isConnected } = useAccount()
const { connectAsync, connectors } = useConnect()
const { disconnect } = useDisconnect()
const [mounted, setMounted] = useState(false)
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [connectError, setConnectError] = useState('')
const [mobileSheetOpen, setMobileSheetOpen] = useState(false)
async function copyAddress(addr: string) {
let ok = false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(addr)
ok = true
} else {
// Fallback for non-secure contexts where the Clipboard API is absent.
const ta = document.createElement('textarea')
ta.value = addr
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
ok = document.execCommand('copy')
document.body.removeChild(ta)
}
} catch {
ok = false
}
setCopied(ok)
setTimeout(() => setCopied(false), 1500)
}
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
setWalletMenuOpen(false)
}, [pathname, address])
useEffect(() => {
if (isConnected) setConnectError('')
}, [isConnected])
const locale = pathname.split('/')[1] || 'en'
const path = '/' + pathname.split('/').slice(2).join('/')
async function handleConnectWallet() {
setConnectError('')
// On mobile without an injected provider, show the wallet deep-link sheet
// instead of throwing "No wallet found". This lets users open the dApp
// inside MetaMask / Trust / Coinbase without a confusing error.
if (needsMobileWallet()) {
setMobileSheetOpen(true)
return
}
try {
const connector = await getFirstReadyConnector(connectors)
if (!connector) {
// Desktop with no extension — give a helpful hint instead of raw error.
setConnectError('No wallet extension found. Install MetaMask or another browser wallet.')
return
}
await connectAsync({ connector })
} catch (err: unknown) {
setConnectError(walletConnectErrorLabel(err))
}
}
// Two independent trading systems get their own top-level tabs — no
// intermediate "Signals" hub. Trump = event scalp; BTC = reversal.
const tabs = [
{ key: '/', label: t('tabs.overview') },
{ key: '/trump', label: t('tabs.trump') },
{ key: '/macro', label: t('tabs.vibes') },
{ key: '/kol', label: t('tabs.kol') },
{ key: '/trades', label: t('tabs.trades') },
{ key: '/analytics', label: t('tabs.analytics') },
{ key: '/settings', label: t('tabs.settings') },
]
function isActive(key: string) {
if (key === '/') return path === '/' || path === '//'
// /posts (legacy hub) still resolves but isn't a tab; treat it + /archive
// as part of the Trump section so a tab stays highlighted there.
if (key === '/trump')
return path.startsWith('/trump') || path.startsWith('/posts') || path.startsWith('/archive')
return path.startsWith(key)
}
const shortAddr = address ? `${address.slice(0, 6)}…${address.slice(-4)}` : null
return (
<>
<MobileWalletSheet open={mobileSheetOpen} onClose={() => setMobileSheetOpen(false)} />
<nav className="nav">
<Link
href={`/${locale}`}
className="brand"
style={{ textDecoration: 'none', color: 'inherit' }}
>
<BrandMark />
<span>{t('brand')}</span>
</Link>
<div className="nav-tabs">
{tabs.map((tab) => (
<Link
key={tab.key}
href={`/${locale}${tab.key === '/' ? '' : tab.key}`}
className={`nav-tab ${isActive(tab.key) ? 'active' : ''}`}
prefetch
>
{tab.label}
</Link>
))}
</div>
<div className="nav-spacer" />
<div className="nav-right">
{/* <LanguageSwitch /> — shelved until i18n coverage is complete */}
<ThemeToggle />
<div className="wallet-anchor">
{!mounted ? (
<button className="connect-btn lg" suppressHydrationWarning>
{t('actions.connectWallet')}
</button>
) : isConnected && shortAddr ? (
<div className="wallet-menu-wrap" style={{ position: 'relative' }}>
<button
className="wallet-chip"
onClick={() => setWalletMenuOpen((open) => !open)}
onBlur={(e) => {
if (!e.currentTarget.parentElement?.contains(e.relatedTarget as Node | null)) {
setWalletMenuOpen(false)
}
}}
aria-expanded={walletMenuOpen}
aria-haspopup="menu"
>
<span className="ava" />
<span className="mono">{shortAddr}</span>
</button>
<div className={`wallet-menu ${walletMenuOpen ? 'open' : ''}`} role="menu">
<button
role="menuitem"
onMouseDown={(e) => {
e.preventDefault()
if (address) copyAddress(address)
}}
>
{copied ? t('actions.copied') : t('actions.copyAddress')}
</button>
<button
role="menuitem"
className="danger"
onMouseDown={(e) => {
e.preventDefault()
disconnect()
setWalletMenuOpen(false)
}}
>
{t('actions.disconnect')}
</button>
</div>
</div>
) : (
<button
className="connect-btn lg"
onClick={() => {
void handleConnectWallet()
}}
>
{t('actions.connectWallet')}
</button>
)}
{!isConnected && connectError ? (
<p className="wallet-connect-error" role="alert">
{connectError}
</p>
) : null}
</div>
</div>
</nav>
</>
)
}
+89
View File
@@ -0,0 +1,89 @@
'use client'
/**
* Ticker — horizontal scrolling price tape under the navbar, the way a crypto
* terminal shows a running market strip. Driven by the shared WebSocket price
* feed (no new socket). Each asset cell flashes green/red on a price change and
* shows the live last price.
*
* Behaviour notes:
* - The strip is duplicated once and translated -50% in a CSS marquee so the
* loop is seamless (`ticker-marquee` keyframe in globals.css).
* - prefers-reduced-motion disables the scroll (CSS), so the tape becomes a
* static, readable row instead of moving.
* - The animation is paused on hover so a user can read a specific price.
* - Assets with no tick yet render "—" rather than a stale zero.
*/
import { useRef, useState } from 'react'
import { useWsSubscribe } from '@/lib/wsContext'
// Assets the backend streams (see binance ASSET_MAP + hl_price_feed). Order is
// the display order in the tape.
const ASSETS = ['BTC', 'ETH', 'SOL', 'BNB', 'DOGE', 'LINK', 'AAVE', 'TRUMP', 'HYPE'] as const
interface Cell {
price: number | null
dir: 'up' | 'down' | null
}
function fmtPrice(p: number): string {
if (p >= 1000) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 0 })
if (p >= 1) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 2 })
return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 4 })
}
export default function Ticker() {
const [cells, setCells] = useState<Record<string, Cell>>({})
// Per-asset flash-clear timers so a fast stream doesn't leak setTimeouts.
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
useWsSubscribe('price', (msg) => {
const m = msg as { asset?: string; price?: number }
if (!m.asset || typeof m.price !== 'number') return
const asset = m.asset.toUpperCase()
if (!ASSETS.includes(asset as (typeof ASSETS)[number])) return
setCells((prev) => {
const old = prev[asset]
const dir: Cell['dir'] =
old?.price != null && m.price !== old.price
? m.price! > old.price
? 'up'
: 'down'
: (old?.dir ?? null)
return { ...prev, [asset]: { price: m.price!, dir } }
})
// Clear the flash after 600ms.
clearTimeout(timers.current[asset])
timers.current[asset] = setTimeout(() => {
setCells((prev) => (prev[asset] ? { ...prev, [asset]: { ...prev[asset], dir: null } } : prev))
}, 600)
})
const strip = ASSETS.map((a) => {
const c = cells[a]
return (
<span key={a} className={`ticker-cell ${c?.dir ? `tick-flash-${c.dir}` : ''}`}>
<span className="ticker-sym">{a}</span>
<span className="ticker-px">{c?.price != null ? fmtPrice(c.price) : '—'}</span>
<span className={`ticker-arrow ${c?.dir ?? ''}`} aria-hidden>
{c?.dir === 'up' ? '▲' : c?.dir === 'down' ? '▼' : ''}
</span>
</span>
)
})
return (
<div className="ticker" role="region" aria-label="Live market prices">
<div className="ticker-track">
{strip}
{/* duplicate for seamless loop */}
<span aria-hidden style={{ display: 'contents' }}>
{strip}
</span>
</div>
</div>
)
}
@@ -0,0 +1,795 @@
'use client'
/**
* Open positions panel — what's currently on the book.
*
* The single most-asked question on any trading dashboard: "what do I hold
* right now?" Polls /api/positions/open + /api/positions/today every 15s.
* Compact enough to embed at the top of Dashboard AND Trades.
*
* Empty state is intentional: explicitly says "0 open" rather than hiding,
* so the user can confirm the bot isn't doing anything weird off-screen.
*/
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import {
getOpenPositions,
getTodayStats,
manualCloseTrade,
setTradeGrow,
type OpenPosition,
type TodayStats,
} from '@/lib/api'
import {
getCachedViewEnvelope,
getOrCreateViewEnvelope,
signRequest,
type SignedEnvelope,
} from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
const POLL_MS = 15_000
function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) {
if (n == null || isNaN(n)) return '—'
const sign = opts.sign === true
const abs = Math.abs(n).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
if (n < 0) return '-$' + abs
if (sign && n > 0) return '+$' + abs
return '$' + abs
}
function fmtPct(n: number | null | undefined) {
if (n == null || isNaN(n)) return '—'
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
}
function fmtHold(min: number) {
if (min < 60) return `${min}m`
const h = Math.floor(min / 60),
m = min % 60
if (h < 24) return `${h}h ${m}m`
return `${Math.floor(h / 24)}d ${h % 24}h`
}
function PositionRow({
p,
onClose,
onToggleGrow,
growBusy,
isZh,
}: {
p: OpenPosition
onClose: (p: OpenPosition) => void
onToggleGrow: (p: OpenPosition) => void
growBusy: boolean
isZh: boolean
}) {
const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up' : (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle'
const pnlColor =
pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)'
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12,
alignItems: 'center',
padding: '10px 14px',
borderTop: '1px solid var(--line)',
fontSize: 13,
}}
>
{/* Asset + paper tag */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
<span style={{ fontWeight: 600 }}>{p.asset}</span>
{p.is_paper && (
<span
style={{
fontSize: 9,
padding: '1px 5px',
borderRadius: 3,
background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}
>
P
</span>
)}
</div>
{/* Side */}
<span className={`side-pill ${p.side}`}>{p.side === 'long' ? '↗ LONG' : '↘ SHORT'}</span>
{/* Entry → Current */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry / current price</div>
<div>
{fmtMoney(p.entry_price)}
<span style={{ color: 'var(--ink-4)' }}> → </span>
{p.current_price != null ? (
fmtMoney(p.current_price)
) : (
<span style={{ color: 'var(--ink-4)' }}>n/a</span>
)}
</div>
</div>
{/* Size + leverage (size = OPEN notional after de-risk) */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
<div>
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
</div>
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
<div style={{ fontSize: 9, marginTop: 2 }}>
{(p.addon_steps ?? 0) > 0 && (
<span style={{ color: 'var(--up)' }}>{`↑ scaled in ×${p.addon_steps} `}</span>
)}
{(p.derisk_steps ?? 0) > 0 && (
<span style={{ color: 'var(--down)' }}>{`↓ trimmed ×${p.derisk_steps}`}</span>
)}
</div>
)}
{/* Per-trade Grow (pyramiding) switch */}
<button
onClick={() => onToggleGrow(p)}
disabled={growBusy}
title={
p.grow_mode
? isZh
? '加仓已开启:趋势确认后自动追加仓位。点击关闭。'
: 'Scale-in ON — bot adds to this position when trend is confirmed. Click to turn off.'
: isZh
? '加仓已关闭:仅持有并做保护性减仓。'
: 'Scale-in OFF — hold + protective trim only. Click to allow adding.'
}
style={{
marginTop: 4,
fontSize: 9,
fontWeight: 700,
padding: '2px 7px',
borderRadius: 999,
cursor: growBusy ? 'wait' : 'pointer',
border: '1px solid var(--line)',
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
}}
>
{p.grow_mode ? '↑ Scale-in ON' : 'Scale-in OFF'}
</button>
</div>
{/* Hold time */}
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
{fmtHold(p.hold_minutes)}
</div>
{/* Unrealized PnL */}
<div style={{ textAlign: 'right' }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: pnlColor,
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtMoney(p.unrealized_usd, { sign: true })}
</div>
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
{fmtPct(p.unrealized_pct)}
</div>
{p.realized_usd != null && p.realized_usd !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
banked {fmtMoney(p.realized_usd, { sign: true })} from partial close
</div>
)}
</div>
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
<button
onClick={() => onClose(p)}
className="btn ghost"
style={{
padding: '6px 10px',
fontSize: 11,
fontWeight: 600,
color: 'var(--down)',
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
}}
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
>
{isZh ? '立即平仓' : 'Close now'}
</button>
</div>
)
}
export default function OpenPositions() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [positions, setPositions] = useState<OpenPosition[] | null>(null)
const [today, setToday] = useState<TodayStats | null>(null)
const [err, setErr] = useState<string | null>(null)
const [needsUnlock, setNeedsUnlock] = useState(false)
const [unlocking, setUnlocking] = useState(false)
// Close-confirmation modal state
const [closing, setClosing] = useState<OpenPosition | null>(null)
const [closeState, setCloseState] = useState<'idle' | 'signing' | 'closing' | 'err'>('idle')
useEffect(() => {
setMounted(true)
}, [])
const [closeErr, setCloseErr] = useState('')
const [growId, setGrowId] = useState<number | null>(null)
// Separate error slot for the Grow toggle so a transient toggle failure
// doesn't squat in the panel-header `err` banner (which is otherwise
// owned by the 15s poll). Auto-clears after 4s.
const [growErr, setGrowErr] = useState('')
useEffect(() => {
if (!growErr) return
const t = setTimeout(() => setGrowErr(''), 4000)
return () => clearTimeout(t)
}, [growErr])
async function toggleGrow(p: OpenPosition) {
if (!address || growId !== null) return
const next = !p.grow_mode
setGrowId(p.trade_id)
setGrowErr('')
try {
const env = await signRequest({
action: 'set_trade_grow',
wallet: address,
body: { trade_id: p.trade_id, enabled: next },
signMessageAsync,
})
const r = await setTradeGrow(env, p.trade_id, next)
setPositions((prev) =>
(prev ?? []).map((x) => (x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x)),
)
} catch (e) {
if (isUserRejection(e)) {
setGrowErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled')
} else {
setGrowErr(
walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90) ||
(isZh ? 'Grow 切换失败' : 'grow toggle failed'),
)
}
} finally {
setGrowId(null)
}
}
useEffect(() => {
// B39: wipe stale data from the previous wallet immediately — don't wait
// for the async fetch to complete before the UI shows a clean slate.
setPositions(null)
setToday(null)
setNeedsUnlock(false)
setErr(null)
if (!isConnected || !address) return
let cancelled = false
// Only reuse a cached envelope here. Open positions should never trigger
// a fresh signature popup on their own while the user is browsing.
async function load() {
const env =
getCachedViewEnvelope('view_positions', address!) ??
getCachedViewEnvelope('view_user', address!)
if (!env) {
if (!cancelled) {
setNeedsUnlock(true)
setErr(null)
}
return
}
try {
const [p, t] = await Promise.all([
getOpenPositions(address!, env),
getTodayStats(address!, env),
])
if (!cancelled) {
setPositions(p.positions)
setToday(t)
setErr(null)
setNeedsUnlock(false)
}
} catch (e: unknown) {
if (!cancelled)
setErr(e instanceof Error ? e.message.slice(0, 80) : isZh ? '加载失败' : 'fetch error')
}
}
void load()
const id = setInterval(() => {
void load()
}, POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
// signMessageAsync and isZh are intentionally excluded: signMessageAsync is
// never called inside this effect (load() uses cached envelopes only), and
// isZh is a compile-time constant (always false). Including either would
// restart the 15-second poll timer on every wagmi reference change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// In-page unlock: mint a view_user envelope (one signature) right here and
// load positions immediately — no detour to the Settings page. view_user is
// a superset accepted by /positions/open and /positions/today.
async function handleUnlock() {
if (!address || unlocking) return
setUnlocking(true)
setErr(null)
try {
const env = await getOrCreateViewEnvelope({
action: 'view_user',
wallet: address,
signMessageAsync,
})
const [p, t] = await Promise.all([
getOpenPositions(address, env),
getTodayStats(address, env),
])
setPositions(p.positions)
setToday(t)
setNeedsUnlock(false)
setErr(null)
} catch (e: unknown) {
// User-cancelled signature is benign — keep the unlock CTA visible.
if (!isUserRejection(e)) {
setErr(e instanceof Error ? e.message.slice(0, 80) : isZh ? '解锁失败' : 'unlock failed')
}
} finally {
setUnlocking(false)
}
}
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
async function confirmCloseTrade() {
if (!address || !closing) return
setCloseErr('')
setCloseState('signing')
try {
const env = await signRequest({
action: 'close_trade',
wallet: address,
body: { trade_id: closing.trade_id },
signMessageAsync,
})
setCloseState('closing')
await manualCloseTrade(env, closing.trade_id)
// Optimistically drop the row from the list; the next poll will confirm.
setPositions((prev) => (prev ?? []).filter((x) => x.trade_id !== closing.trade_id))
setClosing(null)
setCloseState('idle')
} catch (e: unknown) {
// Distinguish a user-cancelled signature (benign, close the modal) from
// a real failure (keep modal open in 'err' state so user can retry or
// read the error).
if (isUserRejection(e)) {
setClosing(null)
setCloseState('idle')
setCloseErr('')
} else {
setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
setCloseState('err')
}
}
}
// Return null both before mount (SSR) and when not connected — same output,
// no hydration mismatch.
if (!mounted || !isConnected) return null
if (needsUnlock && positions === null && today === null) {
return (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div
style={{
fontSize: 11,
color: 'var(--ink-3)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
fontWeight: 600,
marginBottom: 6,
}}
>
Open positions
</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
Sign in to see open positions
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
Sign once on this device to unlock your positions (valid for a few minutes, no
transaction, no gas).
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
marginTop: 10,
flexWrap: 'wrap',
}}
>
<button
className="btn amber"
style={{ fontSize: 13, padding: '8px 16px' }}
disabled={unlocking}
onClick={handleUnlock}
>
{unlocking ? 'Waiting for signature…' : 'Unlock positions'}
</button>
<Link
href={`/${locale}/settings`}
style={{ fontSize: 12, color: 'var(--ink-3)', textDecoration: 'none' }}
>
or go to Settings →
</Link>
</div>
{err && <div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>⚠️ {err}</div>}
</div>
)
}
// When the first load fails, positions and today are still null but err is set.
// Without this guard the component returns null (blank), swallowing the error.
if (positions === null && today === null) {
if (err) {
return (
<div
className="card"
style={{ padding: '12px 16px', marginBottom: 16, fontSize: 12, color: 'var(--down)' }}
>
⚠️ Could not load positions — {err}
</div>
)
}
return null
}
const totalUnrealized = (positions ?? []).reduce((s, p) => s + (p.unrealized_usd ?? 0), 0)
const pnlTone = today
? today.realized_pnl_usd > 0
? 'up'
: today.realized_pnl_usd < 0
? 'down'
: 'idle'
: 'idle'
const todayColor =
pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)'
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header strip — at-a-glance status */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '14px 18px',
flexWrap: 'wrap',
gap: 12,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
<div>
<div
style={{
fontSize: 10,
color: 'var(--ink-3)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
fontWeight: 600,
marginBottom: 2,
}}
>
Open
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{today?.open_count ?? 0}
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
</span>
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div
style={{
fontSize: 10,
color: 'var(--ink-3)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
fontWeight: 600,
marginBottom: 2,
}}
>
Unrealized
</div>
<div
style={{
fontSize: 18,
fontWeight: 700,
color:
totalUnrealized > 0
? 'var(--up)'
: totalUnrealized < 0
? 'var(--down)'
: 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtMoney(totalUnrealized, { sign: true })}
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div
style={{
fontSize: 10,
color: 'var(--ink-3)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
fontWeight: 600,
marginBottom: 2,
}}
>
Today&apos;s realized
</div>
<div
style={{
fontSize: 18,
fontWeight: 700,
color: todayColor,
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
</div>
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`}
</div>
)}
</div>
</div>
{err && <span style={{ fontSize: 11, color: 'var(--down)' }}>● {err}</span>}
{growErr && !err && <span style={{ fontSize: 11, color: 'var(--down)' }}>● {growErr}</span>}
</div>
{/* Position list (or empty state) */}
{positions && positions.length > 0 ? (
<div>
<div
style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12,
padding: '8px 14px',
background: 'var(--bg-sunk)',
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.05em',
color: 'var(--ink-3)',
textTransform: 'uppercase',
}}
>
<span>Asset</span>
<span>Side</span>
<span>Prices</span>
<span>Size</span>
<span>Hold</span>
<span style={{ textAlign: 'right' }}>Unrealized</span>
<span style={{ textAlign: 'right' }}>Action</span>
</div>
{positions.map((p) => (
<PositionRow
key={p.trade_id}
p={p}
onClose={setClosing}
onToggleGrow={toggleGrow}
growBusy={growId === p.trade_id}
isZh={isZh}
/>
))}
</div>
) : (
<div
style={{
borderTop: '1px solid var(--line)',
padding: '20px 18px',
fontSize: 13,
color: 'var(--ink-3)',
textAlign: 'center',
}}
>
No open positions. When the bot opens a trade, it will show up here.
</div>
)}
{/* ── Confirmation modal (P0.1 safety valve) ───────────────────
Two-step UX so the wallet popup isn't surprising. The user
presses "Close now", reads the impact summary, then confirms. */}
{closing && (
<div
onClick={() => {
// Backdrop dismisses in both idle (never started) and err
// (failed, user wants out) states. NOT during signing/closing —
// the wallet popup or in-flight POST shouldn't be orphaned.
if (closeState === 'idle' || closeState === 'err') {
setClosing(null)
setCloseState('idle')
setCloseErr('')
}
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
}}
>
<div
onClick={(e) => e.stopPropagation()}
className="card"
style={{ padding: 24, maxWidth: 440, width: '100%' }}
>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{isZh
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}?`
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
{closing.is_paper
? isZh
? '这是模拟交易,平仓只做本地记录,不会动用真实资金。'
: 'Paper trade — simulated close, no real funds involved.'
: isZh
? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。'
: 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.'}
</div>
<div
style={{
background: 'var(--bg-sunk)',
borderRadius: 8,
padding: 14,
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
marginBottom: 6,
}}
>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
<span className="mono">{fmtMoney(closing.entry_price)}</span>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
marginBottom: 6,
}}
>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Current price'}</span>
<span className="mono">
{closing.current_price != null
? fmtMoney(closing.current_price)
: isZh
? '暂无'
: 'n/a'}
</span>
</div>
{closing.realized_usd != null && closing.realized_usd !== 0 && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
marginBottom: 6,
color: 'var(--ink-3)',
}}
>
<span>{isZh ? '已落袋(之前减仓)' : 'Already banked'}</span>
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
</div>
)}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 13,
fontWeight: 600,
marginTop: 8,
paddingTop: 8,
borderTop: '1px solid var(--line)',
}}
>
<span>{isZh ? '平仓后将结算' : "You'll get if you close now"}</span>
<span
style={{
color:
(closing.unrealized_usd ?? 0) > 0
? 'var(--up)'
: (closing.unrealized_usd ?? 0) < 0
? 'var(--down)'
: 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtMoney(closing.unrealized_usd, { sign: true })}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
({fmtPct(closing.unrealized_pct)})
</span>
</span>
</div>
</div>
{closeState === 'err' && (
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setClosing(null)}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn ghost"
style={{ padding: '8px 16px', fontSize: 13 }}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
onClick={confirmCloseTrade}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn"
style={{
padding: '8px 18px',
fontSize: 13,
fontWeight: 600,
background: 'var(--down)',
color: '#fff',
border: 'none',
}}
>
{closeState === 'signing'
? isZh
? '钱包签名中…'
: 'Sign in wallet…'
: closeState === 'closing'
? isZh
? '平仓中…'
: 'Closing…'
: isZh
? `立即平掉 ${closing.asset}`
: `Close ${closing.asset} now`}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
// Emits a schema.org/BreadcrumbList as JSON-LD. Server-component friendly —
// renders only a <script> tag, no visible DOM. Helps Google breadcrumb rich
// results and gives AI answer engines the page's entity hierarchy.
//
// Usage (server component):
// <Breadcrumbs items={[{ name: 'Methodology', path: '/en/methodology' }]} />
// "Home" is prepended automatically; pass the page itself as the last item.
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
export interface Crumb {
name: string
path: string // e.g. "/en/methodology"
}
export default function Breadcrumbs({ items }: { items: Crumb[] }) {
const all: Crumb[] = [{ name: 'Trump Alpha', path: '/en' }, ...items]
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: all.map((c, i) => ({
'@type': 'ListItem',
position: i + 1,
name: c.name,
item: `${siteUrl}${c.path}`,
})),
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
@@ -0,0 +1,87 @@
'use client'
import type { SignalSource } from '@/lib/api'
interface Props {
sources: SignalSource[]
activeSource: string
onChange: (src: string) => void
/** Count map keyed by source (computed by parent from live post list). */
liveCounts: Record<string, number>
}
function relativeTime(iso: string | null): string {
if (!iso) return '—'
const diff = (Date.now() - new Date(iso).getTime()) / 1000
if (diff < 60) return `${Math.round(diff)}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
return `${Math.round(diff / 86400)}d ago`
}
/**
* Source filter chips for the signals page.
*
* Each chip = one entry from /api/signals/sources. Shows total historical
* count from the server + the (filtered) live count from the current post
* payload — so the user can see "this filter would drop me from 700 to 13".
*/
export default function SourceChips({ sources, activeSource, onChange, liveCounts }: Props) {
// Always have an "all" pseudo-source first
const totalCount = sources.reduce((s, x) => s + x.count, 0)
return (
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
marginBottom: 10,
}}
>
Signal sources
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{/* All */}
<button
onClick={() => onChange('all')}
className={`chip ${activeSource === 'all' ? 'up' : ''}`}
style={{ padding: '8px 14px', cursor: 'pointer', border: '1px solid var(--line)' }}
>
<span style={{ fontWeight: 600 }}>All</span>
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{totalCount}</span>
</button>
{sources.map((s) => (
<button
key={s.source}
onClick={() => onChange(s.source)}
className={`chip ${activeSource === s.source ? 'up' : ''}`}
style={{
padding: '8px 14px',
cursor: 'pointer',
border: '1px solid var(--line)',
background: activeSource === s.source ? 'var(--up-soft)' : undefined,
}}
title={`Last signal: ${relativeTime(s.latest)}`}
>
<span style={{ fontWeight: 600 }}>{s.source}</span>
<span style={{ marginLeft: 8, color: 'var(--ink-4)' }}>{s.count}</span>
{/* Show live filtered count when different from total */}
{liveCounts[s.source] !== undefined && liveCounts[s.source] !== s.count && (
<span style={{ marginLeft: 6, fontSize: 10, color: 'var(--ink-3)' }}>
({liveCounts[s.source]} loaded)
</span>
)}
<span style={{ marginLeft: 8, fontSize: 10, color: 'var(--ink-4)' }}>
· {relativeTime(s.latest)}
</span>
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,483 @@
'use client'
import Link from 'next/link'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* ONE control: the master Auto-Trade switch.
*
* OFF (default, safe) — signals are still scanned & shown in the feed,
* but NO trade is opened. Pure monitoring.
* ON — a qualifying signal auto-opens a trade (full
* System-2 risk: dynamic leverage, staged de-risk,
* ratchet, peak-trail). De-risk / stop-loss always
* run on open positions regardless — never toggleable.
*
* Replaces the old scanner-toggle / timed manual-window / schedule / paper
* trio. Circuit breaker is read-only; turning Auto-Trade ON acknowledges +
* clears a tripped breaker. Per-trade pyramiding is the "Grow" switch on
* each open position (Trades page), not here.
*/
const SYS = {
trump: { idx: '', name: 'Trump Signal', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
btc: { idx: '', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
} as const
const ROW: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '8px 0',
flexWrap: 'wrap',
}
const LABEL: React.CSSProperties = { fontSize: 13, color: 'var(--ink-2)' }
export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const s = SYS[system]
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [pub, setPub] = useState<UserPublic | null>(null)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
useEffect(() => {
setMounted(true)
}, [])
// Generation counter: each address change increments it so any in-flight
// refresh from the previous address can detect it's stale.
// `snap !== address` inside a useCallback is a stale-closure trap — both
// refer to the same closed-over value so the check is always false.
const genRef = useRef(0)
useEffect(() => {
genRef.current++
}, [address])
const refresh = useCallback(async () => {
if (!address) {
setPub(null)
return
}
const gen = genRef.current
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
if (gen !== genRef.current) return // wallet changed while in-flight
setPub(p)
}, [address])
useEffect(() => {
refresh()
const poll = setInterval(refresh, 30_000)
return () => clearInterval(poll)
}, [refresh])
const subscribed = !!pub?.active
const paper = !!pub?.paper_mode
const autoOn = !!pub?.auto_trade
const cbTripped =
!!pub?.circuit_breaker_tripped_at &&
Date.now() - new Date(pub!.circuit_breaker_tripped_at!).getTime() < 24 * 3600 * 1000
async function flipAuto(on: boolean) {
if (busy) return
if (!address) {
setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).')
return
}
if (!subscribed) {
setErr(
isZh
? '钱包尚未订阅,请先去设置页完成订阅。'
: 'Wallet not subscribed — subscribe on the Settings page first.',
)
return
}
// B51: live users without an HL API key cannot actually execute trades —
// block the toggle so the ON state never silently becomes a no-op.
if (on && !paper && !pub?.hl_api_key_set) {
setErr(
isZh
? '未绑定 Hyperliquid API Key。请先在设置页保存 API Key,Auto-Trade 才能真实下单。'
: 'No Hyperliquid API key saved. Add your HL API key on the Settings page before enabling Auto-Trade.',
)
return
}
if (autoOn === on) return
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
// second click on the same pill (or the opposite pill) slips past the
// `if (busy) return` guard and queues a second sheet + second signature.
// We still clear it in `finally` below.
setBusy(true)
setErr('')
// Show pre-signing confirmation sheet before triggering MetaMask
const confirmed = await confirmSign(
on
? {
label: paper
? isZh
? '开启 Auto-Trade(模拟模式)'
: 'Enable Auto-Trade (paper mode)'
: isZh
? '开启 Auto-Trade(真实资金)'
: 'Enable Auto-Trade (live capital)',
description: paper
? isZh
? '机器人将在信号触发时自动开仓(Paper 模式,无真实资金)。钱包签名仅用于身份验证。'
: 'The bot will auto-open positions when a qualifying signal fires in paper mode. No real capital is used. The wallet signature is only for identity verification.'
: isZh
? '机器人将在 Hyperliquid 上用真实资金自动开仓。止损与分级减仓始终生效保护仓位。'
: 'The bot will auto-open real positions on Hyperliquid. Stop-loss and staged de-risking remain active to protect the position.',
danger: !paper,
}
: {
label: isZh ? '关闭 Auto-Trade' : 'Disable Auto-Trade',
description: isZh
? '停止自动开仓。已有持仓的止损与减仓逻辑仍会继续运行。'
: 'Stop opening new trades automatically. Risk controls on existing positions will keep running.',
danger: false,
},
)
if (!confirmed) {
setBusy(false)
return
} // release the slot we claimed
try {
const env = await signRequest({
action: 'set_auto_trade',
wallet: address,
body: { enabled: on },
signMessageAsync,
})
const r = await setAutoTrade(env, on)
setPub((p) =>
p
? {
...p,
auto_trade: r.auto_trade,
...(r.circuit_breaker_cleared
? { circuit_breaker_tripped_at: null, circuit_breaker_reason: null }
: {}),
}
: p,
)
} catch (e) {
if (isUserRejection(e)) {
setErr(isZh ? '已取消' : 'Cancelled')
} else {
setErr(
walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 110) ||
(isZh ? 'Auto-Trade 切换失败' : 'Auto-Trade toggle failed'),
)
}
} finally {
setBusy(false)
}
}
// SSR / pre-hydration: render nothing to avoid hydration mismatch with
// wagmi state that resolves client-side.
if (!mounted) return null
const settingsHref = `/${locale}/settings#bot-config`
// Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a
// trade. bot_engine.process_post early-returns for sys2 sources, so the
// master Auto-Trade switch only governs Trump (System 1). Presenting an
// ON/OFF auto-trade toggle here would (a) falsely imply Macro signals
// auto-open and (b) be a confusing second copy of the SAME global switch
// shown on the Trump page. Show the adopt-only flow instead.
if (system === 'btc') {
// Compact inline strip — signal fires → you open → bot manages.
// No big card; the data panel is the main content. Settings link stays
// for users who want to configure, but the explanation is one line.
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
padding: '9px 14px',
marginBottom: 14,
borderRadius: 8,
background: s.soft,
borderLeft: `3px solid ${s.accent}`,
}}
>
<span style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.4 }}>
<strong style={{ color: s.accent }}>You open · bot manages exit</strong>
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
Signal → Telegram → open on Hyperliquid → <code style={{ fontSize: 11 }}>/adopt</code>
</span>
</span>
<Link
href={settingsHref}
style={{
fontSize: 11,
color: s.accent,
textDecoration: 'none',
fontWeight: 700,
flexShrink: 0,
opacity: 0.8,
}}
>
Settings ↗
</Link>
</div>
)
}
// Wallet not connected: a previous version showed a giant full-width
// "Connect a wallet..." card here, which was redundant with the navbar's
// Connect button. The opposite extreme — rendering null — silently hid
// the existence of Auto-Trade for unconnected users. Compromise: a SLIM
// inline strip that simply announces the feature without pushing the
// page around. Once connected, the full control panel renders below.
if (!isConnected) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
flexWrap: 'wrap',
padding: '10px 14px',
marginBottom: 16,
background: 'var(--bg-sunk)',
border: '1px dashed var(--line)',
borderRadius: 8,
fontSize: 12,
color: 'var(--ink-3)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: 'var(--ink-4)',
flexShrink: 0,
}}
/>
{/* No "{s.name}" prefix — the page title right above already names
the system; repeating it here read "Trump Signal" twice per screen. */}
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>Auto-Trade</span>
<span>·</span>
<span style={{ color: 'var(--ink-4)' }}>OFF — connect wallet (top-right) to enable</span>
</div>
<Link
href={settingsHref}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderRadius: 999,
border: '1px solid var(--line)',
background: 'var(--surface)',
fontSize: 12,
color: 'var(--ink)',
textDecoration: 'none',
fontWeight: 700,
boxShadow: 'var(--shadow-1)',
}}
>
<span>Settings</span>
<span aria-hidden="true">↗</span>
</Link>
</div>
)
}
// ── Net result — the single sentence that matters ─────────────────────────
let netOk = false
let netMsg = ''
if (!subscribed) {
netMsg = isZh
? '钱包还没订阅,请先去设置页完成订阅。'
: 'Wallet not subscribed — subscribe on the Settings page first.'
} else if (cbTripped) {
netMsg = isZh
? `熔断器已触发(${pub?.circuit_breaker_reason || '风险限制'})。重新打开 Auto-Trade 才会确认并恢复。`
: `Circuit breaker tripped (${pub?.circuit_breaker_reason || 'risk limit'}). Turn Auto-Trade ON to acknowledge & resume.`
} else if (!autoOn) {
netMsg = isZh
? 'Auto-Trade 当前为关闭: Trump 信号仍会显示,但不会自动开仓。'
: 'Auto-Trade is OFF — Trump signals are shown in the feed but NOT traded.'
} else if (pub?.trump_enabled === false) {
// Auto-Trade is ON but the Trump (System-1) gate is OFF, so the backend
// skips every Trump signal (bot_engine: trump_enabled OFF → not traded).
// Without this branch the UI falsely promised "next signal auto-opens".
netMsg = isZh
? 'Auto-Trade 已开启,但 Trump 系统①未启用: 在设置页打开 Trump 开关后才会自动开仓。'
: 'Auto-Trade is ON, but the Trump system is disabled — enable Trump in Settings before signals will trade.'
} else {
netOk = true
netMsg = isZh
? `Auto-Trade 已开启(Trump 系统①): 下一条合格 Trump 信号会自动开出${paper ? '模拟' : '真实'}仓位。`
: `Auto-Trade ON — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
}
const Pill = ({
active,
onClick,
children,
tone,
}: {
active: boolean
onClick: () => void
children: React.ReactNode
tone: 'green' | 'idle'
}) => {
const bg = active ? (tone === 'green' ? 'var(--up, #16a34a)' : 'var(--ink-3)') : 'transparent'
return (
<button
onClick={onClick}
disabled={busy}
style={{
padding: '8px 20px',
fontSize: 13,
fontWeight: 800,
borderRadius: 8,
cursor: busy ? 'wait' : 'pointer',
border: active ? `1px solid ${bg}` : '1px solid var(--line)',
background: active ? bg : 'transparent',
color: active ? '#fff' : 'var(--ink-3)',
}}
>
{children}
</button>
)
}
return (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header */}
<div
style={{ padding: '14px 16px', background: s.soft, borderLeft: `4px solid ${s.accent}` }}
>
{/* Header is just "Auto-Trade" — the page title already names the
system, and the switch row below repeats "Auto-Trade" otherwise. */}
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{isZh ? '控制面板' : 'Auto-Trade'}
</div>
</div>
{/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}>
<div style={ROW}>
{/* The card header above already says "Auto-Trade" — this row only
carries the ON/OFF legend, not a second title. */}
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
{isZh
? 'OFF = 只监控信号 · ON = 自动开仓'
: 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">
ON
</Pill>
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">
OFF
</Pill>
</div>
</div>
{/* Read-only status */}
<div style={{ ...ROW, borderTop: '1px solid var(--line)', marginTop: 8, paddingTop: 12 }}>
<span style={LABEL}>{isZh ? '执行模式' : 'Execution mode'}</span>
<span style={{ fontSize: 12, fontWeight: 700, color: paper ? '#f59e0b' : 'var(--up)' }}>
{paper ? (isZh ? '📝 模拟' : '📝 PAPER') : isZh ? '💰 真实资金' : '💰 LIVE'}
<span style={{ color: 'var(--ink-4)', fontWeight: 400, marginLeft: 8 }}>
{isZh ? '· 订阅时确定' : '· set when subscribing'}
</span>
</span>
</div>
<div style={ROW}>
<span style={LABEL}>{isZh ? '熔断器' : 'Circuit breaker'}</span>
<span
style={{
fontSize: 12,
fontWeight: 700,
color: cbTripped ? 'var(--down)' : 'var(--up)',
}}
>
{cbTripped
? isZh
? `🚨 已触发(${pub?.circuit_breaker_reason})`
: `🚨 tripped (${pub?.circuit_breaker_reason})`
: isZh
? '✓ 正常'
: '✓ clear'}
</span>
</div>
{err && <div style={{ fontSize: 11, color: 'var(--down)', marginTop: 10 }}>● {err}</div>}
</div>
{/* Net result */}
<div
style={{
padding: '14px 16px',
background: netOk ? 'var(--up-soft)' : 'var(--down-soft, rgba(220,38,38,.08))',
borderTop: '1px solid var(--line)',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '.06em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
marginBottom: 4,
}}
>
{isZh ? '⟹ 当前状态' : '⟹ Current status'}
</div>
<div style={{ fontSize: 14, fontWeight: 700, color: netOk ? 'var(--up)' : 'var(--down)' }}>
{netOk ? '✅ ' : '⛔ '}
{netMsg}
</div>
<div style={{ marginTop: 8 }}>
<Link
href={settingsHref}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
borderRadius: 999,
border: '1px solid var(--line)',
background: 'var(--surface)',
fontSize: 12,
color: 'var(--ink)',
textDecoration: 'none',
fontWeight: 700,
boxShadow: 'var(--shadow-1)',
}}
>
<span>Settings</span>
<span aria-hidden="true">↗</span>
</Link>
</div>
</div>
</div>
)
}
@@ -0,0 +1,481 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getTelegramStatus, tgInit, tgUnbind, type TelegramStatus } from '@/lib/api'
import { signRequest } from '@/lib/signedRequest'
import { walletErrorLabel } from '@/lib/walletError'
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
/**
* Settings → Telegram alerts card.
*
* Three states:
* 1. Server not configured → show "ask the admin to set up the bot"
* 2. Not bound → deep-link to bot + optional wallet-link code
* 3. Bound → status line + link to bot for preferences
*
* Preferences are managed entirely inside the bot (/trump /btc /funding /kol
* /conf /quiet). This card is just discovery + wallet-linking for Pro users.
*/
export default function TelegramCard() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [mounted, setMounted] = useState(false)
const [status, setStatus] = useState<TelegramStatus | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [code, setCode] = useState<{ code: string; deep_link: string } | null>(null)
useEffect(() => {
setMounted(true)
}, [])
// Generation counter guards against stale-closure trap: `snap !== address`
// inside useCallback compares two closed-over copies of the same value and
// is always false. genRef is a mutable ref readable from any closure.
const genRef = useRef(0)
useEffect(() => {
genRef.current++
}, [address])
const refresh = useCallback(async () => {
if (!address) {
setLoading(false)
setStatus(null)
return
}
const gen = genRef.current
setLoading(true)
try {
const { getCachedViewEnvelope } = await import('@/lib/signedRequest')
const cached = getCachedViewEnvelope('view_user', address)
const s = await getTelegramStatus(address.toLowerCase(), cached ?? undefined)
if (gen !== genRef.current) return // wallet changed while in-flight
setStatus(s)
setErr('')
} catch (e) {
if (gen !== genRef.current) return
setErr(e instanceof Error ? e.message : 'load failed')
} finally {
if (gen === genRef.current) setLoading(false)
}
}, [address])
// B32: clear stale status immediately when wallet changes so the previous
// wallet's binding info never flickers in before the new fetch resolves.
useEffect(() => {
setStatus(null)
setCode(null)
setErr('')
}, [address])
useEffect(() => {
refresh()
}, [refresh])
// Poll while waiting for wallet-link to complete.
useEffect(() => {
if (!code || status?.bound) return
const id = setInterval(refresh, 3000)
return () => clearInterval(id)
}, [code, status?.bound, refresh])
const botUsername = status?.bot_username ?? 'TrumpAlpha_bot'
const botLink = `https://t.me/${botUsername}`
async function handleLinkWallet() {
if (!address) return
setBusy(true)
setErr('')
try {
const confirmed = await confirmSign({
label: 'Generate Telegram link code',
description:
'A one-time 6-char code valid for 10 min. Paste it into the bot to link your wallet for Pro features.',
})
if (!confirmed) {
setBusy(false)
return
}
const env = await signRequest({
action: 'telegram_init',
wallet: address,
body: null,
signMessageAsync,
})
const r = await tgInit(address.toLowerCase(), env)
setCode({ code: r.code, deep_link: r.deep_link })
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally {
setBusy(false)
}
}
async function handleUnbind() {
if (!address) return
setBusy(true)
setErr('')
try {
const confirmed = await confirmSign({
label: 'Disconnect Telegram',
description:
'Unlinks this wallet from Telegram. Your free subscription stays active in the bot.',
})
if (!confirmed) {
setBusy(false)
return
}
const env = await signRequest({
action: 'telegram_unbind',
wallet: address,
body: null,
signMessageAsync,
})
await tgUnbind(address.toLowerCase(), env)
setCode(null)
await refresh()
} catch (e) {
setErr(walletErrorLabel(e, 'Cancelled'))
} finally {
setBusy(false)
}
}
// ── Render ───────────────────────────────────────────────────────────────
if (!mounted) return null
if (!isConnected) {
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>Telegram alerts</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6 }}>
You can get free Telegram alerts without a wallet — just open the bot. Connect a wallet
here to also get alerts tailored to your own positions.
</div>
</div>
)
}
return (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
gap: 12,
flexWrap: 'wrap',
}}
>
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
}}
>
{isZh ? 'Telegram 提醒' : 'Telegram alerts'}
</div>
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 2 }}>
{isZh
? '高置信度信号触发时发送推送提醒'
: 'Push notifications when high-conviction signals fire'}
</div>
</div>
{status?.bound && status.alerts_enabled && (
<span
style={{
fontSize: 11,
color: 'var(--up)',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 6,
background: 'var(--up-soft)',
}}
>
● {isZh ? '已开启' : 'Active'}
</span>
)}
{status?.bound && !status.alerts_enabled && (
<span
style={{
fontSize: 11,
color: 'var(--ink-4)',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 6,
background: 'var(--bg-sunk)',
}}
>
○ {isZh ? '已暂停' : 'Paused'}
</span>
)}
</div>
{loading && <div className="skeleton sk-line sk-w-full" style={{ marginBottom: 8 }} />}
{/* Server not configured */}
{!loading && status && !status.configured && (
<div
style={{
padding: 12,
fontSize: 12,
color: 'var(--ink-3)',
background: 'var(--bg-sunk)',
borderRadius: 6,
}}
>
⚠️{' '}
{isZh
? '当前服务器还没有配置 Telegram 提醒。请让管理员设置'
: 'Telegram alerts are not configured on this server. Ask the operator to set'}
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_TOKEN</code> and
<code style={{ margin: '0 4px' }}>TELEGRAM_BOT_USERNAME</code>.
</div>
)}
{/* Configured — main body */}
{!loading && status?.configured && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Bot link row — always visible */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderRadius: 8,
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
}}
>
<span style={{ fontSize: 18 }}>✈️</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>
<a
href={botLink}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', textDecoration: 'none' }}
>
@{botUsername} ↗
</a>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
{status.bound
? isZh
? '在机器人里可开关各类提醒、设置免打扰时段'
: 'Open the bot to choose which alerts you get and set quiet hours'
: isZh
? '打开机器人并发送 /start,无需额外账号'
: 'Open the bot and tap Start — no account needed'}
</div>
</div>
</div>
{/* Bound: wallet status + disconnect */}
{status.bound && (
<div
style={{
fontSize: 12,
color: 'var(--ink-3)',
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<span>
{status.tg_username ? (
<>
{isZh ? '已绑定账号' : 'Linked as'}{' '}
<strong style={{ color: 'var(--ink)' }}>@{status.tg_username}</strong>
</>
) : (
<>
{isZh ? '聊天 ID' : 'Chat'}{' '}
<strong style={{ color: 'var(--ink)' }}>#{status.chat_id}</strong>
</>
)}
</span>
{status.wallet_address ? (
<>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>
{isZh
? `已发送 ${status.total_alerts_sent ?? 0} 条提醒`
: `${status.total_alerts_sent ?? 0} alerts sent`}
</span>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<button
className="btn ghost"
disabled={busy}
onClick={handleUnbind}
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}
>
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
</button>
</>
) : (
<>
<span style={{ color: 'var(--ink-4)' }}>·</span>
<span>
{isZh
? `已发送 ${status.total_alerts_sent ?? 0} 条提醒`
: `${status.total_alerts_sent ?? 0} alerts sent`}
</span>
</>
)}
</div>
)}
{/* Not bound: optional wallet-link for Pro */}
{!status.bound && !code && (
<div style={{ fontSize: 12, color: 'var(--ink-4)' }}>
{isZh ? '已经在机器人里了?' : 'Already in the bot?'}{' '}
<button
onClick={handleLinkWallet}
disabled={busy}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--amber, #f59e0b)',
fontSize: 12,
padding: 0,
textDecoration: 'underline',
}}
>
{isZh ? '绑定这个钱包以启用 Pro 功能' : 'Link this wallet for Pro features'}
</button>
</div>
)}
{/* Wallet-link code flow */}
{!status.bound && code && (
<WalletLinkPanel
code={code.code}
link={code.deep_link}
onCancel={() => setCode(null)}
isZh={isZh}
/>
)}
</div>
)}
{err && <div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>● {err}</div>}
</div>
)
}
// ── Subcomponents ────────────────────────────────────────────────────────────
function WalletLinkPanel({
code,
link,
onCancel,
isZh,
}: {
code: string
link: string
onCancel: () => void
isZh: boolean
}) {
const [copied, setCopied] = useState(false)
function copy() {
navigator.clipboard
.writeText(code)
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
.catch(() => {})
}
return (
<div
style={{
padding: 14,
background: 'var(--bg-sunk)',
borderRadius: 8,
border: '1px solid var(--line)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
{isZh ? '绑定钱包(Pro):' : 'Link wallet (Pro):'}
</div>
<ol
style={{
paddingLeft: 18,
fontSize: 12,
color: 'var(--ink-2)',
lineHeight: 1.7,
marginBottom: 12,
}}
>
<li>
{isZh ? '打开机器人:' : 'Open the bot:'}&nbsp;
<a
href={link}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--amber, #f59e0b)', fontWeight: 600 }}
>
{link.replace('https://', '')} ↗
</a>
</li>
<li>
{isZh ? '发送' : 'Send'}{' '}
<code style={{ background: 'var(--bg)', padding: '1px 6px', borderRadius: 3 }}>
/start {code}
</code>
<button
onClick={copy}
style={{
marginLeft: 6,
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
border: '1px solid var(--line)',
background: 'var(--bg)',
cursor: 'pointer',
color: 'var(--ink-3)',
}}
>
{copied ? (isZh ? '✓ 已复制' : '✓ copied') : isZh ? '复制验证码' : 'copy code'}
</button>
</li>
</ol>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginBottom: 10 }}>
{isZh
? '验证码 10 分钟内有效,绑定成功后这里会自动刷新。'
: 'Code expires in 10 min. This panel updates automatically once linked.'}
</div>
<button
onClick={onCancel}
style={{
fontSize: 11,
color: 'var(--ink-3)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
{isZh ? '取消' : 'Cancel'}
</button>
</div>
)
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -0,0 +1,562 @@
'use client'
import { useState, useMemo, useEffect } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade } from '@/types'
import Pagination from '@/components/ui/Pagination'
// ── Formatters ────────────────────────────────────────────────────────────────
function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
const { decimals = 2, sign = false } = opts
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
if (n < 0) return '-$' + s
if (sign && n > 0) return '+$' + s
return '$' + s
}
function fmtPct(n: number) {
return (n >= 0 ? '+' : '') + n.toFixed(2) + '%'
}
function fmtHold(s: number | null | undefined) {
if (s == null) return '—'
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
interface Props {
trades: BotTrade[]
loading: boolean
locked?: boolean // true = waiting for wallet signature, not "no trades"
}
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
const SIDES = ['all', 'long', 'short'] as const
// Human-readable labels for raw signal-source identifiers. Keeps the trade
// history readable for non-technical users (matches PostCards source labels).
const SOURCE_LABEL: Record<string, string> = {
truth: 'Trump',
btc_bottom_reversal: 'BTC Macro Bottom',
funding_reversal: 'Funding Reversal',
kol_divergence: 'KOL Divergence',
sma_reclaim: 'SMA Reclaim',
rsi_reversal: 'RSI Reversal',
breakout: 'Breakout',
adopted: 'Adopted',
manual: 'Manual',
unknown: 'Unknown',
}
function sourceLabel(src: string): string {
return SOURCE_LABEL[src?.toLowerCase()] ?? src
}
const TRADES_PER_PAGE = 25
export default function TradeTable({ trades, loading, locked = false }: Props) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [assetFilter, setAssetFilter] = useState('all')
const [sideFilter, setSideFilter] = useState('all')
const [sourceFilter, setSourceFilter] = useState('all')
const [hidePaper, setHidePaper] = useState(false)
const [page, setPage] = useState(1)
// Distinct assets present in the loaded trade set — drives the asset filter.
// Dynamic so new perps (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear without
// a code change; sorted alphabetically with BTC/ETH first for familiarity.
const assets: string[] = useMemo(() => {
const set = new Set<string>()
for (const t of trades) if (t.asset) set.add(t.asset)
const priority = ['BTC', 'ETH', 'SOL', 'TRUMP']
const rest = Array.from(set)
.filter((a) => !priority.includes(a))
.sort()
return ['all', ...priority.filter((a) => set.has(a)), ...rest]
}, [trades])
// Distinct sources present in the loaded trade set — drives the filter UI.
// Includes 'unknown' as a bucket for trades whose trigger post was deleted.
const sources = useMemo(() => {
const set = new Set<string>()
for (const t of trades) set.add(t.trigger_source || 'unknown')
return Array.from(set).sort()
}, [trades])
// Reset filters that no longer apply to the loaded trade set. On a wallet
// switch the trades prop changes but the local filter state persists, so a
// source/asset/side the previous wallet had could stay "stuck" — and when
// the new wallet has a single source the breakdown card (sources.length > 1)
// disappears, removing the only Clear affordance. Clamping here guarantees
// the user always sees their full new history.
useEffect(() => {
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
}, [sources, assets, sourceFilter, assetFilter])
// Per-source PnL aggregate — the critical view for "which module makes money".
// Apply asset/side/paper filters for the source breakdown so the cards stay
// consistent with the KPI and table rows below. We intentionally do NOT apply
// sourceFilter here — filtering by source would trivially make one card 100%.
const filteredForSources = useMemo(
() =>
trades.filter((t) => {
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
}),
[trades, assetFilter, sideFilter, hidePaper],
)
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of filteredForSources) {
const k = t.trigger_source || 'unknown'
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
acc[k].trades += 1
if (t.pnl_usd != null) {
acc[k].pnl += t.pnl_usd
if (t.pnl_usd > 0) acc[k].wins += 1
}
if (t.is_paper) acc[k].paper += 1
}
return acc
}, [filteredForSources])
const filtered = useMemo(
() =>
trades.filter((t) => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
}),
[trades, sourceFilter, assetFilter, sideFilter, hidePaper],
)
const totalPages = Math.max(1, Math.ceil(filtered.length / TRADES_PER_PAGE))
const safePage = Math.min(page, totalPages)
const pageRows = filtered.slice((safePage - 1) * TRADES_PER_PAGE, safePage * TRADES_PER_PAGE)
// Exclude externally-closed trades (pnl_usd null) from aggregates.
const priced = filtered.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined)
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
const wins = priced.filter((t) => (t.pnl_usd ?? 0) > 0).length
const losses = priced.length - wins
const withHold = filtered.filter((t) => t.hold_seconds !== null)
const avgHold =
withHold.length > 0
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
: 0
return (
<>
{/* ── Per-source breakdown (the "which module makes money" view) ── */}
{sources.length > 1 && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: 'var(--ink-3)',
marginBottom: 10,
}}
>
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: 10,
}}
>
{sources.map((src) => {
const s = perSource[src]
const winRate = s.trades ? (s.wins / s.trades) * 100 : 0
const tone = s.pnl > 0 ? 'up' : s.pnl < 0 ? 'down' : 'idle'
const bg =
tone === 'up'
? 'var(--up-soft)'
: tone === 'down'
? 'var(--down-soft)'
: 'var(--bg-sunk)'
const fg =
tone === 'up' ? 'var(--up)' : tone === 'down' ? 'var(--down)' : 'var(--ink-2)'
return (
<button
key={src}
onClick={() => {
setSourceFilter(sourceFilter === src ? 'all' : src)
setPage(1)
}}
style={{
textAlign: 'left',
cursor: 'pointer',
background: bg,
borderRadius: 8,
padding: '12px 14px',
border: `1px solid ${sourceFilter === src ? fg : 'transparent'}`,
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--ink-2)',
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{sourceLabel(src)}
{s.paper > 0 && (
<span
style={{
fontSize: 9,
marginLeft: 6,
padding: '1px 5px',
borderRadius: 3,
background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}
>
{s.paper}P
</span>
)}
</div>
<div
style={{
fontSize: 16,
fontWeight: 700,
color: fg,
fontVariantNumeric: 'tabular-nums',
}}
>
{fmtMoney(s.pnl, { sign: true, decimals: 0 })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh
? `${s.trades} 笔交易 · ${winRate.toFixed(0)}% 胜率`
: `${s.trades} trades · ${winRate.toFixed(0)}% win`}
</div>
</button>
)
})}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 8 }}>
{isZh ? '点击来源可筛选表格。' : 'Click a source to filter the table.'}{' '}
{sourceFilter !== 'all' && (
<button
onClick={() => {
setSourceFilter('all')
setPage(1)
}}
style={{
marginLeft: 8,
padding: '2px 8px',
fontSize: 10,
border: '1px solid var(--line)',
borderRadius: 4,
background: 'transparent',
cursor: 'pointer',
color: 'var(--ink-2)',
}}
>
{isZh
? `清除(当前为 “${sourceLabel(sourceFilter)}”)`
: `Clear (showing “${sourceLabel(sourceFilter)}”)`}
</button>
)}
</div>
</div>
)}
{/* KPI row */}
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
<div className="kpi">
<div className="label">{isZh ? '总交易数' : 'Total trades'}</div>
<div className="value">{filtered.length}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '胜率' : 'Win rate'}</div>
<div className="value">
{priced.length ? ((wins / priced.length) * 100).toFixed(1) + '%' : '—'}
</div>
<div className="foot">
<span>{isZh ? `${wins} 赢 · ${losses} 亏` : `${wins}W · ${losses}L`}</span>
</div>
</div>
<div className="kpi accent">
<div className="label">{isZh ? '净盈亏' : 'Net P&L'}</div>
<div className="value">{fmtMoney(totalPnl, { sign: true, decimals: 0 })}</div>
</div>
<div className="kpi">
<div className="label">{isZh ? '平均持仓' : 'Avg hold'}</div>
<div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div>
</div>
</div>
{/* Filters */}
<div
style={{
display: 'flex',
gap: 10,
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: 14,
}}
>
<div className="nav-tabs">
{assets.map((a) => (
<button
key={a}
className={`nav-tab ${assetFilter === a ? 'active' : ''}`}
onClick={() => {
setAssetFilter(a)
setPage(1)
}}
>
{a === 'all' ? (isZh ? '全部资产' : 'All assets') : a}
</button>
))}
</div>
<div className="nav-tabs">
{SIDES.map((s) => (
<button
key={s}
className={`nav-tab ${sideFilter === s ? 'active' : ''}`}
onClick={() => {
setSideFilter(s)
setPage(1)
}}
>
{s === 'all'
? isZh
? '全部方向'
: 'All'
: s === 'long'
? isZh
? '做多'
: 'Long'
: isZh
? '做空'
: 'Short'}
</button>
))}
</div>
{/* Hide-paper toggle: paper trades inflate volume but aren't real $ — */}
{/* let users blend them out before reading the KPI row. */}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: 'var(--ink-3)',
cursor: 'pointer',
marginLeft: 'auto',
}}
>
<input
type="checkbox"
checked={hidePaper}
onChange={(e) => {
setHidePaper(e.target.checked)
setPage(1)
}}
/>
{isZh ? '隐藏模拟交易' : 'Hide paper trades'}
</label>
</div>
{/* Loading state */}
{loading && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>
{isZh ? '加载中…' : 'Loading…'}
</div>
)}
{/* Table */}
{!loading && (
<div className="card flush" style={{ overflow: 'hidden' }}>
<table className="table">
<thead>
<tr>
{/* P&L right after Side — it's the column this page exists for.
As the last of 8 columns it sat off-screen on phones (the
table scrolls inside its card) until the user swiped past
entry/exit/hold/trigger to find it. */}
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>P&amp;L</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td
colSpan={8}
style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}
>
{locked
? isZh
? '签名解锁后即可查看交易历史。'
: 'Sign to unlock your trade history above.'
: isZh
? '没有符合条件的交易。'
: 'No trades found'}
</td>
</tr>
)}
{pageRows.map((t) => {
const roi =
t.entry_price && t.exit_price != null
? ((t.exit_price - t.entry_price) / t.entry_price) *
100 *
(t.side === 'long' ? 1 : -1)
: 0
const src = t.trigger_source || 'unknown'
return (
<tr key={t.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--ink-2)',
padding: '2px 7px',
borderRadius: 4,
background: 'var(--bg-sunk)',
}}
>
{sourceLabel(src)}
</span>
{t.is_paper && (
<span
style={{
fontSize: 9,
padding: '1px 5px',
borderRadius: 3,
background: 'rgba(245,158,11,0.15)',
color: '#f59e0b',
}}
>
PAPER
</span>
)}
</div>
</td>
<td>
<div className="row gap-s">
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
<span style={{ fontWeight: 500 }}>{t.asset}</span>
</div>
</td>
<td>
<span className={`side-pill ${t.side}`}>
{t.side === 'long'
? isZh
? '↗ 做多'
: '↗ LONG'
: isZh
? '↘ 做空'
: '↘ SHORT'}
</span>
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-start' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
title={
isZh
? '在 Hyperliquid 外部平仓,PnL 未记录'
: 'Closed externally on Hyperliquid — PnL not recorded'
}
>
n/a
</span>
) : (
<>
<span
className={`delta ${t.pnl_usd >= 0 ? 'up' : 'down'}`}
style={{ fontWeight: 600, fontSize: 14 }}
>
{fmtMoney(t.pnl_usd, { sign: true })}
</span>
<span
className={`delta ${roi >= 0 ? 'up' : 'down'}`}
style={{ fontSize: 11, opacity: 0.7 }}
>
{fmtPct(roi)}
</span>
</>
)}
</div>
</td>
<td className="mono">
{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}
</td>
<td className="mono">
{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}
</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>
{fmtHold(t.hold_seconds)}
</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span
style={{
fontSize: 12,
color: 'var(--ink-2)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{t.trigger_post_text.slice(0, 60)}…
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{!loading && (
<Pagination
page={safePage}
total={totalPages}
count={filtered.length}
pageSize={TRADES_PER_PAGE}
onChange={setPage}
scrollTop={false}
/>
)}
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
'use client'
/**
* AnimatedNumber — flashes green/red when its value changes, the way a live
* trading terminal ticks. Pure presentational: it renders `display` (already
* formatted) but watches `value` (the raw number) to decide flash direction.
*
* The flash is a CSS class toggled for ~600ms via a keyframe (`tick-flash-up` /
* `tick-flash-down` in globals.css). Respects prefers-reduced-motion (the CSS
* keyframes are disabled there, so this degrades to a plain number).
*/
import { useEffect, useRef, useState } from 'react'
interface Props {
/** Raw numeric value — drives the flash direction when it changes. */
value: number | null | undefined
/** Pre-formatted string to actually show (e.g. "$72,140"). */
display: string
/** Extra className(s) for the wrapper. */
className?: string
style?: React.CSSProperties
}
export default function AnimatedNumber({ value, display, className = '', style }: Props) {
const prev = useRef<number | null | undefined>(value)
const [dir, setDir] = useState<'up' | 'down' | null>(null)
useEffect(() => {
const p = prev.current
if (value != null && p != null && value !== p) {
setDir(value > p ? 'up' : 'down')
const id = setTimeout(() => setDir(null), 600)
prev.current = value
return () => clearTimeout(id)
}
prev.current = value
}, [value])
return (
<span className={`tick-num ${dir ? `tick-flash-${dir}` : ''} ${className}`} style={style}>
{display}
</span>
)
}
+89
View File
@@ -0,0 +1,89 @@
'use client'
/**
* Inline "?"" hint icon with a hover tooltip.
*
* Use this for any UI element whose meaning isn't obvious from its label —
* stat boxes, tab names, jargon-y settings. Goal is the same as the (i)
* dots in Linear / Hyperliquid / Coinglass: small, ignorable, one hover
* away from a one-line plain-English explanation.
*
* Design rules:
* * `text` MUST be one short sentence — never paste long docs in here.
* If you need a paragraph, you're explaining the wrong thing.
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
* should disappear visually until the user actively scans for help.
* * The tooltip uses CSS hover positioning — no portals. The only JS is
* an on-reveal measurement that clamps the bubble inside the viewport
* (a centred bubble on an icon near the screen edge used to hang
* off-screen, worst on phones). Trade-off: it's still clipped by the
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
* a `<table>` cell should add `overflow: visible` to that row/td.
*
* Usage:
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
*
* <InfoTip
* text="Last 24h of cycles, averaged. Smooths the noise of one print."
* placement="bottom" // default 'top'
* />
*/
import { useRef } from 'react'
import type { CSSProperties } from 'react'
type Placement = 'top' | 'bottom' | 'left' | 'right'
interface Props {
text: string
placement?: Placement
/** Tip max-width in px. Default 240 — fits ~2 short lines. */
width?: number
/** Tweak the icon's vertical alignment for fussy contexts. */
iconStyle?: CSSProperties
}
export default function InfoTip({ text, placement = 'top', width = 240, iconStyle }: Props) {
const rootRef = useRef<HTMLSpanElement>(null)
// Clamp the bubble inside the viewport when it's revealed. The bubble is
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
// Measured at shift=0, then the needed horizontal offset is written to the
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
function clampIntoViewport() {
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
if (!bubble) return
bubble.style.setProperty('--tip-shift', '0px')
const r = bubble.getBoundingClientRect()
const vw = document.documentElement.clientWidth
const margin = 8
let shift = 0
if (r.left < margin) shift = margin - r.left
else if (r.right > vw - margin) shift = vw - margin - r.right
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
}
return (
<span
ref={rootRef}
className={`infotip infotip-${placement}`}
// role=button is a slight overstatement but it lets screen-reader users
// know there's a thing to tab to. The actual content is in data-tip and
// also as a regular <span> child for non-CSS-tooltip fallback.
role="button"
tabIndex={0}
aria-label={text}
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
onMouseEnter={clampIntoViewport}
onFocus={clampIntoViewport}
onTouchStart={clampIntoViewport}
>
<span aria-hidden="true" className="infotip-icon">
?
</span>
<span className="infotip-bubble" role="tooltip">
{text}
</span>
</span>
)
}
+27
View File
@@ -0,0 +1,27 @@
/**
* One-line "what is this page" tagline that sits right under the page title.
*
* Replaces ad-hoc <p className="page-sub"> spots that ended up as ghost-gray
* unreadable status lines ("Macro cycle bottom · 2 signals"). The new contract:
*
* * ALWAYS one short sentence — game-tutorial style. Tells the user the
* "what" in plain English, not the "how" (the how lives in InfoTip's).
* * Slightly bigger + higher contrast than page-sub. Meant to be read,
* not glanced past.
* * Optional `count` slot for the dim stat ("2 signals", "30 posts").
* Renders muted after a separator so the tagline itself stays clean.
*/
export default function PageHint({
children,
count,
}: {
children: React.ReactNode
count?: string
}) {
return (
<p className="page-hint">
{children}
{count && <span className="page-hint-count">{count}</span>}
</p>
)
}
+233
View File
@@ -0,0 +1,233 @@
'use client'
import React from 'react'
interface PaginationProps {
page: number
total: number // total pages
count: number // total items
pageSize: number
onChange: (page: number) => void
/** Scroll to top on page change. Default true. */
scrollTop?: boolean
}
/**
* Shared pagination control used across all list pages.
*
* Visual design:
* ‹ 1 2 … 5 [6] 7 … 12 › Showing 151–180 of 340
*
* Active page uses brand orange. Ellipsis collapses distant pages.
* Prev/Next disabled at boundaries (dimmed, not hidden).
*/
export default function Pagination({
page,
total,
count,
pageSize,
onChange,
scrollTop = true,
}: PaginationProps) {
if (total <= 1) return null
const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, count)
function go(p: number) {
if (p < 1 || p > total || p === page) return
onChange(p)
if (scrollTop) window.scrollTo({ top: 0, behavior: 'smooth' })
}
/** Page number list with ellipsis compression. */
function pages(): (number | '…')[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
const out: (number | '…')[] = []
const push = (n: number | '…') => {
if (out[out.length - 1] !== n) out.push(n)
}
push(1)
if (page > 3) push('…')
for (let i = Math.max(2, page - 1); i <= Math.min(total - 1, page + 1); i++) push(i)
if (page < total - 2) push('…')
push(total)
return out
}
return (
<div style={wrapStyle}>
{/* ← Prev */}
<NavBtn onClick={() => go(page - 1)} disabled={page === 1} label="Previous page">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M9 11L5 7l4-4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span>Prev</span>
</NavBtn>
{/* Page numbers */}
<div style={numsStyle}>
{pages().map((n, i) =>
n === '…' ? (
<span key={`e${i}`} style={ellipsisStyle}>
…
</span>
) : (
<PageBtn key={n} num={n as number} active={n === page} onClick={go} />
),
)}
</div>
{/* Next → */}
<NavBtn onClick={() => go(page + 1)} disabled={page === total} label="Next page">
<span>Next</span>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M5 3l4 4-4 4"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</NavBtn>
{/* Count */}
<span style={countStyle}>
{from}–{to} <span style={{ opacity: 0.5 }}>of</span> {count.toLocaleString()}
</span>
</div>
)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function PageBtn({
num,
active,
onClick,
}: {
num: number
active: boolean
onClick: (n: number) => void
}) {
const [hover, setHover] = React.useState(false)
const bg = active ? 'var(--amber)' : hover ? 'var(--bg-sunk)' : 'transparent'
const color = active ? '#fff' : hover ? 'var(--ink)' : 'var(--ink-2)'
const border = active ? '1.5px solid var(--amber)' : '1.5px solid var(--line)'
return (
<button
onClick={() => onClick(num)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
aria-current={active ? 'page' : undefined}
style={{
width: 34,
height: 34,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
border,
background: bg,
color,
fontSize: 13,
fontWeight: active ? 700 : 400,
cursor: 'pointer',
transition: 'background 0.12s, color 0.12s, border-color 0.12s',
boxShadow: active ? '0 1px 4px rgba(245,165,36,0.25)' : 'none',
flexShrink: 0,
}}
>
{num}
</button>
)
}
function NavBtn({
onClick,
disabled,
label,
children,
}: {
onClick: () => void
disabled: boolean
label: string
children: React.ReactNode
}) {
const [hover, setHover] = React.useState(false)
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
onMouseEnter={() => !disabled && setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
height: 34,
padding: '0 12px',
borderRadius: 8,
border: '1.5px solid var(--line)',
background: hover && !disabled ? 'var(--bg-sunk)' : 'transparent',
color: disabled ? 'var(--ink-4)' : hover ? 'var(--ink)' : 'var(--ink-2)',
fontSize: 12,
fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 0.12s, color 0.12s',
flexShrink: 0,
}}
>
{children}
</button>
)
}
// ── Styles ────────────────────────────────────────────────────────────────────
const wrapStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
padding: '28px 0 8px',
flexWrap: 'wrap',
}
const numsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
justifyContent: 'center',
}
const ellipsisStyle: React.CSSProperties = {
width: 28,
textAlign: 'center',
fontSize: 13,
color: 'var(--ink-4)',
userSelect: 'none',
flexShrink: 0,
}
const countStyle: React.CSSProperties = {
fontSize: 11,
color: 'var(--ink-3)',
fontVariantNumeric: 'tabular-nums',
marginLeft: 6,
whiteSpace: 'nowrap',
}
+202
View File
@@ -0,0 +1,202 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useAccount } from 'wagmi'
import { useWsSubscribe } from '@/lib/wsContext'
interface TradeAlert {
id: number
event:
| 'execution_failed'
| 'insufficient_balance'
| 'budget_reached'
| 'reconcile_drift'
| 'circuit_breaker_tripped'
asset?: string
reason?: string
balance_usd?: number
required_usd?: number
spent_usd?: number
cap_usd?: number
// reconcile_drift fields (from reconciler.py broadcast)
// orphan_hl is a list of {asset, trade_id} dicts, NOT a number
orphan_hl?: unknown[]
marked_closed?: number
ghost_positions?: unknown[]
// circuit_breaker_tripped fields (from circuit_breaker.py broadcast)
system?: string
cb_reason?: string
unlock_at?: string
}
// How long each alert stays visible before auto-dismissing.
const AUTO_DISMISS_MS = 10_000
function alertMessage(alert: TradeAlert): string {
switch (alert.event) {
case 'execution_failed':
return `Auto-trade failed${alert.asset ? ` on ${alert.asset}` : ''}${alert.reason ? `: ${alert.reason}` : ''}. Check your HL API key and account status.`
case 'insufficient_balance':
return `Insufficient balance to open${alert.asset ? ` ${alert.asset}` : ''}: account has $${alert.balance_usd?.toFixed(2) ?? '?'}, trade requires $${alert.required_usd?.toFixed(2) ?? '?'}. Top up your Hyperliquid account.`
case 'budget_reached':
return `Daily budget reached${alert.asset ? ` (${alert.asset})` : ''}: $${alert.spent_usd?.toFixed(0) ?? '?'} spent of $${alert.cap_usd?.toFixed(0) ?? '?'} cap. No more trades today.`
// B47: reconciler detects DB ↔ HL mismatch (ghost/orphan position)
case 'reconcile_drift': {
const parts = []
const orphanCount = (alert.orphan_hl as unknown[])?.length ?? 0
if (orphanCount > 0) parts.push(`${orphanCount} orphan(s) on HL`)
if (alert.marked_closed) parts.push(`${alert.marked_closed} auto-closed`)
if ((alert.ghost_positions as unknown[])?.length)
parts.push(`${(alert.ghost_positions as unknown[]).length} ghost(s)`)
return `Position drift detected: ${parts.join(', ') || 'mismatch between DB and HL'}. Check your Hyperliquid account.`
}
// B53: circuit breaker fired — Auto-Trade suspended
case 'circuit_breaker_tripped':
return `Circuit breaker tripped${alert.system ? ` (${alert.system})` : ''}${alert.cb_reason ? `: ${alert.cb_reason}` : ''}. Auto-Trade suspended — turn it back ON on the Trump page to acknowledge and resume.`
default:
return 'Auto-trade event — check your account.'
}
}
let _seq = 0
export default function TradeAlertBanner() {
const { address } = useAccount()
const [alerts, setAlerts] = useState<TradeAlert[]>([])
const dismiss = useCallback((id: number) => {
setAlerts((prev) => prev.filter((a) => a.id !== id))
}, [])
// Auto-dismiss each alert after AUTO_DISMISS_MS.
// Effect re-runs whenever the alert list changes. For each alert we schedule
// a timeout; cleanup cancels all pending timers when the list updates or
// the component unmounts — prevents a dismissed-then-re-added alert from
// triggering a ghost dismiss.
useEffect(() => {
if (alerts.length === 0) return
const timers = alerts.map((a) => window.setTimeout(() => dismiss(a.id), AUTO_DISMISS_MS))
return () => timers.forEach(clearTimeout)
}, [alerts, dismiss])
useWsSubscribe(
'trade_alert',
useCallback(
(raw) => {
const msg = raw as { wallet?: string; event?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
const alert = { id: ++_seq, ...msg } as TradeAlert
setAlerts((prev) => [alert, ...prev].slice(0, 3))
},
[address],
),
)
// B47: reconcile_drift — DB↔HL position mismatch flagged by the reconciler.
useWsSubscribe(
'reconcile_drift',
useCallback(
(raw) => {
const msg = raw as { wallet?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
const alert = { id: ++_seq, event: 'reconcile_drift' as const, ...msg } as TradeAlert
setAlerts((prev) => [alert, ...prev].slice(0, 3))
},
[address],
),
)
// B53: circuit_breaker_tripped — Auto-Trade suspended by the CB.
useWsSubscribe(
'circuit_breaker_tripped',
useCallback(
(raw) => {
const msg = raw as { wallet?: string; reason?: string } & Record<string, unknown>
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
// Rename 'reason' → 'cb_reason' to avoid collision with the trade_alert 'reason' field.
const { reason: cb_reason, ...rest } = msg
const alert = {
id: ++_seq,
event: 'circuit_breaker_tripped' as const,
cb_reason,
...rest,
} as TradeAlert
setAlerts((prev) => [alert, ...prev].slice(0, 3))
},
[address],
),
)
if (alerts.length === 0) return null
return (
<>
<style>{`
@keyframes tradeAlertIn {
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes tradeAlertOut {
from { opacity: 1; }
to { opacity: 0; }
}
`}</style>
<div
style={{
position: 'fixed',
top: 60,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999,
display: 'flex',
flexDirection: 'column',
gap: 8,
width: 'min(480px, calc(100vw - 32px))',
pointerEvents: 'none',
animation: 'tradeAlertIn 0.2s ease',
}}
>
{alerts.map((alert) => (
<div
key={alert.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
padding: '12px 14px',
borderRadius: 10,
background: 'var(--bg)',
border: '1px solid var(--down)',
boxShadow: '0 4px 20px rgba(0,0,0,.3)',
pointerEvents: 'auto',
}}
>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>⚠️</span>
<div style={{ flex: 1, fontSize: 12, lineHeight: 1.5, color: 'var(--ink)' }}>
<strong style={{ color: 'var(--down)', display: 'block', marginBottom: 2 }}>
Auto-trade blocked
</strong>
{alertMessage(alert)}
</div>
<button
onClick={() => dismiss(alert.id)}
style={{
flexShrink: 0,
padding: 0,
border: 'none',
background: 'transparent',
cursor: 'pointer',
color: 'var(--ink-4)',
fontSize: 16,
lineHeight: 1,
}}
aria-label="Dismiss"
>
×
</button>
</div>
))}
</div>
</>
)
}
@@ -0,0 +1,219 @@
'use client'
/**
* MobileWalletSheet — bottom-drawer shown on mobile when no injected wallet
* is detected. Offers deep links to common mobile wallets so the user can
* open MetaMask (or Trust / Coinbase / OKX) and land directly on this dApp.
*
* Rendered as a fixed overlay + slide-up panel. Backdrop tap dismisses it.
* The CSS animation class `mobile-sheet-enter` is defined in globals.css.
*
* Usage:
* <MobileWalletSheet open={open} onClose={() => setOpen(false)} />
*/
import { useEffect, useState } from 'react'
import { getWalletLinks } from '@/lib/mobileWallet'
import type { WalletLink } from '@/lib/mobileWallet'
interface Props {
open: boolean
onClose: () => void
}
export default function MobileWalletSheet({ open, onClose }: Props) {
const [links, setLinks] = useState<WalletLink[]>([])
// Build deep links client-side only (needs window.location.href).
useEffect(() => {
if (open) {
setLinks(getWalletLinks(window.location.href))
}
}, [open])
// Lock body scroll while sheet is open.
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}
}, [open])
// Escape key closes.
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
/* Backdrop */
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9000,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'flex-end',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
onClick={onClose}
aria-modal="true"
role="dialog"
aria-label="Connect a wallet"
>
{/* Sheet */}
<div
className="mobile-sheet-enter"
style={{
width: '100%',
background: 'var(--bg-elev)',
borderRadius: '20px 20px 0 0',
padding: '20px 20px 32px',
boxShadow: '0 -8px 40px rgba(0,0,0,0.18)',
maxHeight: '80vh',
overflowY: 'auto',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div
style={{
width: 40,
height: 4,
background: 'var(--line-2)',
borderRadius: 99,
margin: '0 auto 20px',
}}
/>
{/* Header */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 4 }}>
Connect a wallet
</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.5 }}>
Open the dApp inside your wallet's built-in browser to connect.
</div>
</div>
{/* Wallet list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{links.map((link) => (
<a
key={link.name}
href={link.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 'var(--r-md)',
border: '1px solid var(--line)',
background: 'var(--surface)',
textDecoration: 'none',
color: 'inherit',
transition: 'background 120ms, border-color 120ms',
}}
onTouchStart={(e) => {
;(e.currentTarget as HTMLAnchorElement).style.background = 'var(--bg-sunk)'
}}
onTouchEnd={(e) => {
;(e.currentTarget as HTMLAnchorElement).style.background = 'var(--surface)'
}}
>
{/* Icon */}
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: link.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: link.abbr.length > 2 ? 12 : 20,
fontWeight: 700,
color: '#fff',
flexShrink: 0,
letterSpacing: '-0.01em',
}}
>
{link.abbr}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{link.name}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{link.hint}</div>
</div>
{/* Arrow */}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
style={{ color: 'var(--ink-3)', flexShrink: 0 }}
>
<path
d="M9 18l6-6-6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
))}
</div>
{/* WalletConnect note */}
<div
style={{
marginTop: 18,
padding: '12px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-sunk)',
fontSize: 12,
color: 'var(--ink-3)',
lineHeight: 1.55,
}}
>
<strong style={{ color: 'var(--ink-2)' }}>Already in your wallet's browser?</strong> Tap
Cancel to dismiss, then use the built-in browser navigation to reload the page — MetaMask
should auto-detect the dApp.
</div>
{/* Close */}
<button
onClick={onClose}
style={{
marginTop: 14,
width: '100%',
padding: '14px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
fontSize: 14,
fontWeight: 500,
color: 'var(--ink-2)',
}}
>
Cancel
</button>
</div>
</div>
)
}
@@ -0,0 +1,259 @@
'use client'
/**
* SignConfirmSheet — elegant pre-signing confirmation UI.
*
* Usage (imperative, no context needed):
*
* import { confirmSign } from '@/components/wallet/SignConfirmSheet'
*
* const ok = await confirmSign({
* label: 'Enable Auto-Trade',
* description: 'Bot will open live trades on Hyperliquid when signals fire.',
* danger: true,
* })
* if (!ok) return // user cancelled
* const env = await signRequest(...) // only NOW triggers MetaMask
*/
import { createRoot } from 'react-dom/client'
export interface SignConfirmOptions {
/** Short action title, e.g. "Enable Auto-Trade" */
label: string
/** One-sentence explanation shown to the user */
description: string
/** If true, renders a red/warning accent — for irreversible / live-money actions */
danger?: boolean
}
// Track the currently-mounted sheet so a second confirmSign() call can resolve
// the first one with `false` instead of leaving its promise dangling forever.
let _activeCancel: (() => void) | null = null
/** Renders a confirmation sheet and resolves true (confirm) or false (cancel). */
export function confirmSign(opts: SignConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
// If another sheet is already mounted, cancel it first so its awaiting
// caller resolves with `false` rather than hanging indefinitely.
if (_activeCancel) {
try {
_activeCancel()
} catch {
/* ignore */
}
}
const existing = document.getElementById('sign-confirm-sheet-root')
if (existing?.parentNode) {
existing.parentNode.removeChild(existing)
}
const container = document.createElement('div')
container.id = 'sign-confirm-sheet-root'
document.body.appendChild(container)
const root = createRoot(container)
let settled = false
function cleanup(result: boolean) {
if (settled) return
settled = true
// Clear the active-cancel slot only if it still points to *this* sheet —
// a later confirmSign() may have already replaced it.
if (_activeCancel === cancelSelf) _activeCancel = null
root.unmount()
if (container.parentNode) {
container.parentNode.removeChild(container)
}
resolve(result)
}
// Stable reference used both as the active-cancel handle and inside cleanup.
const cancelSelf = () => cleanup(false)
_activeCancel = cancelSelf
root.render(<Sheet {...opts} onConfirm={() => cleanup(true)} onCancel={cancelSelf} />)
})
}
/* ─── Internal sheet component ─────────────────────────────────────────────── */
function Sheet({
label,
description,
danger,
onConfirm,
onCancel,
}: SignConfirmOptions & { onConfirm: () => void; onCancel: () => void }) {
const accent = danger ? '#ef4444' : '#f5a524'
const accentSoft = danger ? 'rgba(239,68,68,0.1)' : 'rgba(245,165,36,0.1)'
// Dismiss on backdrop click
function handleBackdrop(e: React.MouseEvent<HTMLDivElement>) {
if (e.target === e.currentTarget) onCancel()
}
// Dismiss on Escape
function handleKey(e: React.KeyboardEvent) {
if (e.key === 'Escape') onCancel()
}
return (
<div
onClick={handleBackdrop}
onKeyDown={handleKey}
role="dialog"
aria-modal="true"
aria-label={label}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.70)',
backdropFilter: 'blur(3px)',
WebkitBackdropFilter: 'blur(3px)',
zIndex: 99999,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
padding: '0 0 env(safe-area-inset-bottom, 0)',
}}
>
<div
style={{
width: '100%',
maxWidth: 480,
background: '#111',
borderRadius: '16px 16px 0 0',
border: '1px solid #2a2a2a',
borderBottom: 'none',
padding: '28px 24px 32px',
boxShadow: '0 -12px 48px rgba(0,0,0,0.5)',
animation: 'signSheetIn 0.22s cubic-bezier(0.32,0.72,0,1)',
}}
>
{/* 拖拽条 */}
<div
style={{
width: 36,
height: 4,
borderRadius: 2,
background: '#333',
margin: '0 auto 24px',
}}
/>
{/* 图标 + 标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: accentSoft,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
flexShrink: 0,
}}
>
🔏
</div>
<div>
<div
style={{
fontSize: 11,
color: '#666',
letterSpacing: '0.07em',
textTransform: 'uppercase',
marginBottom: 2,
}}
>
Wallet signature required
</div>
<div style={{ fontSize: 17, fontWeight: 700, color: '#fff', lineHeight: 1.2 }}>
{label}
</div>
</div>
</div>
{/* 说明文字 */}
<div
style={{
fontSize: 14,
color: '#999',
lineHeight: 1.6,
margin: '16px 0 24px',
paddingLeft: 52, // 与标题对齐
}}
>
{description}
</div>
{/* 说明条 */}
<div
style={{
padding: '10px 12px',
borderRadius: 8,
background: 'rgba(255,255,255,0.04)',
border: '1px solid #2a2a2a',
fontSize: 12,
color: '#666',
lineHeight: 1.5,
marginBottom: 20,
}}
>
Signing is used only to verify your identity. It will NOT authorize any on-chain transfer
and will NOT cost any gas. The MetaMask popup will appear after you click Confirm.
</div>
{/* 按钮组 */}
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={onCancel}
style={{
flex: 1,
padding: '12px 0',
borderRadius: 10,
border: '1px solid #2a2a2a',
background: 'transparent',
color: '#888',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={onConfirm}
autoFocus
style={{
flex: 2,
padding: '12px 0',
borderRadius: 10,
border: 'none',
background: accent,
color: danger ? '#fff' : '#000',
fontSize: 14,
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}}
>
<span>Open MetaMask</span>
<span style={{ opacity: 0.7, fontSize: 16 }}>→</span>
</button>
</div>
</div>
<style>{`
@keyframes signSheetIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
`}</style>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import nextVitals from 'eslint-config-next/core-web-vitals'
const config = [
...nextVitals,
{
rules: {
'react/no-unescaped-entities': 'off',
'react-hooks/purity': 'off',
'react-hooks/refs': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
},
},
]
export default config
+3
View File
@@ -0,0 +1,3 @@
export const locales = ['en'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'en'
+649
View File
@@ -0,0 +1,649 @@
import type {
TrumpPost,
Candle,
BotTrade,
BotPerformance,
KolPostSummary,
KolPostDetail,
KolDigest,
KolWallet,
KolHoldingChange,
KolDivergence,
} from '@/types'
import type { SignedEnvelope } from './signedRequest'
function getServerApiBase() {
return (
process.env.API_URL ||
process.env.API_BASE_URL ||
process.env.INTERNAL_API_URL ||
process.env.NEXT_PUBLIC_API_URL ||
'http://localhost:8000'
)
}
function getApiOrigin() {
return typeof window === 'undefined' ? `${getServerApiBase()}/api` : '/api/proxy/api'
}
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${getApiOrigin()}${path}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
let detail = `API error ${res.status}`
try {
const j = await res.json()
if (j.detail) detail = `${res.status}: ${j.detail}`
} catch {}
throw new Error(detail)
}
return res.json() as Promise<T>
}
/** Signed-read credentials as headers (C3): keeps EIP-191 signatures out of
* URLs, access logs, and browser history. Backend still accepts the legacy
* ?ts=&sig= query params for old clients. */
function signedReadInit(env: SignedEnvelope): RequestInit {
return {
headers: {
'Content-Type': 'application/json',
'X-Sig-Ts': String(env.timestamp),
'X-Sig-Sig': env.signature,
},
}
}
export interface PostListResponse {
items: TrumpPost[]
total: number
page: number
limit: number
counts: {
all: number
actionable: number
buy: number
short: number
off_topic: number
}
source_counts: {
source: string
count: number
latest: string | null
}[]
}
export async function getPostsPage(
limit = 20,
page = 1,
source?: string,
filters?: {
sourceIn?: string[]
sourceNotIn?: string[]
archiveOnly?: boolean
sentiment?: 'bullish' | 'bearish' | 'neutral'
signal?: 'buy' | 'short' | 'actionable'
aiScoredOnly?: boolean
},
): Promise<PostListResponse> {
const params = new URLSearchParams({
limit: String(limit),
page: String(page),
})
if (source) params.set('source', source)
if (filters?.sourceIn?.length) params.set('source_in', filters.sourceIn.join(','))
if (filters?.sourceNotIn?.length) params.set('source_not_in', filters.sourceNotIn.join(','))
if (filters?.archiveOnly) params.set('archive_only', 'true')
if (filters?.sentiment) params.set('sentiment', filters.sentiment)
if (filters?.signal) params.set('signal', filters.signal)
if (filters?.aiScoredOnly) params.set('ai_scored_only', 'true')
const q = `/posts-paged?${params.toString()}`
return fetchJson<PostListResponse>(q)
}
export async function getPosts(limit = 20, page = 1, source?: string): Promise<TrumpPost[]> {
const q =
`/posts?limit=${limit}&page=${page}` + (source ? `&source=${encodeURIComponent(source)}` : '')
return fetchJson<TrumpPost[]>(q)
}
export async function getPost(id: number): Promise<TrumpPost> {
return fetchJson<TrumpPost>(`/posts/${id}`)
}
// asset widened from 'BTC' | 'ETH' to string — chart will eventually support
// SOL/TRUMP etc. once the backend /prices/{asset} endpoint covers them.
export async function getPrices(asset: string, tf: string): Promise<Candle[]> {
return fetchJson<Candle[]>(`/prices/${asset}?tf=${tf}`)
}
export async function getTrades(
wallet: string,
env: SignedEnvelope,
limit = 20,
page = 1,
): Promise<BotTrade[]> {
const qs = `wallet=${wallet.toLowerCase()}&limit=${limit}&page=${page}`
return fetchJson<BotTrade[]>(`/trades?${qs}`, signedReadInit(env))
}
export async function getPerformance(wallet: string, env: SignedEnvelope): Promise<BotPerformance> {
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<BotPerformance>(`/performance?${qs}`, signedReadInit(env))
}
export async function subscribe(
env: SignedEnvelope,
options: { paper_mode?: boolean } = {},
): Promise<{ status: string; wallet: string; paper_mode: boolean }> {
// paper_mode is a top-level body field (not nested) so the canonical
// signed body matches the backend's expectation: { paper_mode: true } | null.
const payload = options.paper_mode ? { ...env, paper_mode: true } : env
return fetchJson<{ status: string; wallet: string; paper_mode: boolean }>(`/subscribe`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
export interface UserSettings {
leverage: number
position_size_usd: number
take_profit_pct: number | null
stop_loss_pct: number | null
min_confidence: number
daily_budget_usd: number | null
/** System-2 (bottom reversal) leverage, 1–10. null = platform default.
* Independent of `leverage` (Trump). The protective stop auto-scales to
* this so the position is never exchange-liquidated. */
sys2_leverage: number | null
/** System-2 risk mode: 'standard' (cycle-rider) or 'aggressive'
* (separately-funded high-risk/high-explosiveness sleeve). */
sys2_mode?: 'standard' | 'aggressive' | null
active_from: string | null // ISO UTC
active_until: string | null // ISO UTC
/** Master on/off switches for each module. false = module is disabled;
* bot ignores all signals for that module even if subscribed. */
trump_enabled?: boolean | null
macro_enabled?: boolean | null
}
export interface UserData {
wallet_address: string
active: boolean
subscribed_at: string | null
hl_api_key_set: boolean
hl_api_key_masked: string | null
paper_mode: boolean
trades: BotTrade[]
settings: UserSettings
/** ISO-UTC timestamp until which the bot is manually armed (null = use schedule). */
manual_window_until: string | null
}
export async function setUserSettings(
env: SignedEnvelope,
settings: UserSettings,
): Promise<UserSettings> {
return fetchJson<UserSettings>(`/user/${env.wallet}/settings`, {
method: 'PUT',
body: JSON.stringify({ ...env, settings }),
})
}
export interface UserPublic {
wallet_address: string
active: boolean
hl_api_key_set: boolean
/** Phase 1 operational state — surfaced on /signals page. */
paper_mode?: boolean
manual_window_until?: string | null
circuit_breaker_tripped_at?: string | null
circuit_breaker_reason?: string | null
/** Master Auto-Trade gate. false (default) = signals shown, not traded. */
auto_trade?: boolean
/** Per-system enable flags. bot_engine gates System-1 (Trump) on
* trump_enabled and System-2 (Macro) on macro_enabled. Auto-Trade ON
* with trump_enabled=false still does NOT open on a Trump signal. */
trump_enabled?: boolean
macro_enabled?: boolean
}
export interface SignalSource {
source: string
count: number
latest: string | null
}
export async function getSignalSources(): Promise<{ sources: SignalSource[] }> {
return fetchJson<{ sources: SignalSource[] }>(`/signals/sources`)
}
/**
* Toggle paper mode for a wallet. Hits the dev endpoint — no signature
* required. Only available in development environments where the dev router
* is mounted; in production this will 404.
*/
export async function setPaperMode(
wallet: string,
enabled: boolean,
): Promise<{ status: string; paper_mode: boolean }> {
return fetchJson<{ status: string; paper_mode: boolean }>(
`/dev/paper-mode?wallet=${wallet.toLowerCase()}&enabled=${enabled}`,
{ method: 'POST' },
)
}
// ─── Open positions + today stats ───────────────────────────────────────────
export interface OpenPosition {
trade_id: number
asset: string
side: 'long' | 'short'
entry_price: number
current_price: number | null
size_usd: number | null
leverage: number | null
opened_at: string
hold_minutes: number
unrealized_pct: number | null
unrealized_usd: number | null
is_paper: boolean
trigger_post_id: number | null
/** System-2 staged lifecycle. size_usd above is the OPEN notional after
* de-risk; realized_usd is PnL already banked by partial de-risk. */
realized_usd?: number | null
derisk_steps?: number
addon_steps?: number
/** Per-trade pyramiding switch. */
grow_mode?: boolean
}
export interface OpenPositionsResponse {
wallet: string
count: number
positions: OpenPosition[]
}
export interface TodayStats {
wallet: string
realized_pnl_usd: number
trades_closed: number
wins: number
losses: number
open_count: number
/** Cumulative PnL banked by staged de-risk on still-open positions.
* Shown separately — not part of realized_pnl_usd (closed trades only). */
open_realized_usd?: number
}
export async function getOpenPositions(
wallet: string,
env: SignedEnvelope,
): Promise<OpenPositionsResponse> {
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`, signedReadInit(env))
}
export async function getTodayStats(wallet: string, env: SignedEnvelope): Promise<TodayStats> {
const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<TodayStats>(`/positions/today?${qs}`, signedReadInit(env))
}
// ─── Scanner control (module 2) ─────────────────────────────────────────────
export interface ScannerSummary {
name: string
enabled: boolean
last_run_at: string | null
last_status: string
last_message: string | null
last_fired_at: string | null
total_runs: number
total_fires: number
consecutive_errors: number
}
export interface ScannersResponse {
count: number
enabled: number
scanners: ScannerSummary[]
}
export async function getScanners(): Promise<ScannersResponse> {
return fetchJson<ScannersResponse>(`/scanners`)
}
/**
* Toggle ONE scanner. Signed + subscriber-gated on the backend: the wallet
* must own the signature AND already be a subscriber. The signed body is
* { enabled, name } — must match the backend's canonical hash exactly.
*/
export async function toggleScanner(
env: SignedEnvelope,
name: string,
enabled: boolean,
): Promise<{ status: string; name: string; enabled: boolean }> {
return fetchJson<{ status: string; name: string; enabled: boolean }>(`/scanners/${name}/toggle`, {
method: 'POST',
body: JSON.stringify({ ...env, name, enabled }),
})
}
export async function killAllScanners(
env: SignedEnvelope,
): Promise<{ status: string; count: number }> {
return fetchJson<{ status: string; count: number }>(`/scanners/all/disable`, {
method: 'POST',
body: JSON.stringify(env),
})
}
export async function reviveAllScanners(
env: SignedEnvelope,
): Promise<{ status: string; count: number }> {
return fetchJson<{ status: string; count: number }>(`/scanners/all/enable`, {
method: 'POST',
body: JSON.stringify(env),
})
}
export interface CloseTradeResponse {
status: string
trade_id: number
exit_price: number | null
pnl_usd: number | null
reason: string
note: string | null
}
/**
* Manual emergency close. Requires a signed envelope produced for the action
* "close_trade" with body { trade_id }. The backend re-checks wallet ownership.
*/
export async function manualCloseTrade(
env: SignedEnvelope,
trade_id: number,
): Promise<CloseTradeResponse> {
return fetchJson<CloseTradeResponse>(`/positions/${trade_id}/close`, {
method: 'POST',
body: JSON.stringify({ ...env, trade_id }),
})
}
/** No-auth boolean state. Safe to call on every page load. */
export async function getUserPublic(wallet: string): Promise<UserPublic> {
return fetchJson<UserPublic>(`/user/${wallet.toLowerCase()}/public`)
}
/** Full user data (trades, settings). Requires signed envelope. */
export async function getUser(wallet: string, env: SignedEnvelope): Promise<UserData> {
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}`, signedReadInit(env))
}
export interface WindowAccuracy {
checked: number
correct: number
accuracy_pct: number
}
export interface SignalAccuracyEntry {
count: number
m5: WindowAccuracy
m15: WindowAccuracy
m1h: WindowAccuracy
}
export interface SignalAccuracy {
overall: { m5: WindowAccuracy; m15: WindowAccuracy; m1h: WindowAccuracy }
by_signal: Record<string, SignalAccuracyEntry>
total_directional_signals: number
}
export async function getSignalAccuracy(): Promise<SignalAccuracy> {
return fetchJson<SignalAccuracy>(`/signals/accuracy`)
}
export async function setHlApiKey(
env: SignedEnvelope,
api_key: string,
): Promise<{ status: string; masked_key: string }> {
return fetchJson<{ status: string; masked_key: string }>(`/user/${env.wallet}/hl-api-key`, {
method: 'PUT',
body: JSON.stringify({ ...env, api_key }),
})
}
/**
* Arm (or clear) the manual-window override. Pass `hours = 0` to disarm.
* Backend stores `manual_window_until = now + hours`; when in the future the
* bot trades regardless of the schedule.
*/
/** Master Auto-Trade switch (signed). enabled=true also acknowledges +
* clears a tripped circuit breaker. */
export async function setAutoTrade(
env: SignedEnvelope,
enabled: boolean,
): Promise<{ auto_trade: boolean; circuit_breaker_cleared: boolean }> {
return fetchJson<{ auto_trade: boolean; circuit_breaker_cleared: boolean }>(
`/user/${env.wallet}/auto-trade`,
{ method: 'POST', body: JSON.stringify({ ...env, enabled }) },
)
}
/** Per-trade Grow (pyramiding) switch (signed). */
export async function setTradeGrow(
env: SignedEnvelope,
trade_id: number,
enabled: boolean,
): Promise<{ trade_id: number; grow_mode: boolean }> {
return fetchJson<{ trade_id: number; grow_mode: boolean }>(`/positions/${trade_id}/grow`, {
method: 'POST',
body: JSON.stringify({ ...env, trade_id, enabled }),
})
}
export async function setManualWindow(
env: SignedEnvelope,
hours: number,
): Promise<{ manual_window_until: string | null }> {
return fetchJson<{ manual_window_until: string | null }>(`/user/${env.wallet}/manual-window`, {
method: 'POST',
body: JSON.stringify({ ...env, hours }),
})
}
// ── KOL module ────────────────────────────────────────────────────
export async function getKolPosts(
opts: {
handle?: string
source?: string
signalsOnly?: boolean
ticker?: string
days?: number
limit?: number
page?: number
} = {},
): Promise<{ items: KolPostSummary[]; page: number; limit: number; total: number }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
if (opts.source) p.set('source', opts.source)
if (opts.signalsOnly) p.set('signals_only', 'true')
if (opts.ticker) p.set('ticker', opts.ticker)
if (opts.days) p.set('days', String(opts.days))
p.set('limit', String(opts.limit ?? 50))
p.set('page', String(opts.page ?? 1))
return fetchJson(`/kol/posts?${p.toString()}`)
}
export async function getKolPost(id: number): Promise<KolPostDetail> {
return fetchJson<KolPostDetail>(`/kol/posts/${id}`)
}
export async function getKolDigest(days = 7): Promise<KolDigest> {
return fetchJson<KolDigest>(`/kol/digest?days=${days}`)
}
export async function getKolWallets(handle?: string): Promise<{ wallets: KolWallet[] }> {
const p = handle ? `?handle=${handle}` : ''
return fetchJson<{ wallets: KolWallet[] }>(`/kol/wallets${p}`)
}
export async function addKolWallet(body: {
handle: string
chain: string
address: string
label?: string
source_url?: string
}): Promise<KolWallet> {
return fetchJson<KolWallet>(`/kol/wallets`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function getKolChanges(
opts: {
handle?: string
days?: number
} = {},
): Promise<{ window_days: number; since: string; count: number; changes: KolHoldingChange[] }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
p.set('days', String(opts.days ?? 7))
return fetchJson(`/kol/changes?${p.toString()}`)
}
export async function getKolDivergence(
opts: {
handle?: string
ticker?: string
signal_type?: 'divergence' | 'alignment'
days?: number
} = {},
): Promise<{ window_days: number; since: string; count: number; items: KolDivergence[] }> {
const p = new URLSearchParams()
if (opts.handle) p.set('handle', opts.handle)
if (opts.ticker) p.set('ticker', opts.ticker)
if (opts.signal_type) p.set('signal_type', opts.signal_type)
p.set('days', String(opts.days ?? 30))
return fetchJson(`/kol/divergence?${p.toString()}`)
}
// ── Funding rate reversal (BTC tab) ─────────────────────────────────────────
export interface FundingSnapshot {
ok: boolean
error?: string
asset?: string
venue?: string
cadence_hours?: number
coverage_days?: number
latest_rate_pct?: number
last_24h_avg_pct?: number
cum_30d_pct?: number
extreme_threshold_pct?: number
signal_fired?: boolean
debug?: { reason?: string; direction?: string; [k: string]: unknown }
history?: { t: number; rate_pct: number }[]
}
export async function getFundingSnapshot(): Promise<FundingSnapshot> {
return fetchJson<FundingSnapshot>('/funding/snapshot')
}
// ── Macro snapshot (BTC page Macro Bottom tab) ──────────────────────────────
export interface MacroSnapshot {
ok: boolean
error?: string
date?: string
captured_at?: string
indicators?: {
ahr999: number | null
altcoin_season_index: number | null
fear_greed: number | null
fear_greed_label: string | null
btc_dominance_pct: number | null
eth_btc_ratio: number | null
stablecoin_supply_usd: number | null
etf_flow_net_usd_1d: number | null
btc_open_interest_usd: number | null
}
composite_score?: number | null
regime_label?: string | null
}
export async function getMacroSnapshot(): Promise<MacroSnapshot> {
return fetchJson<MacroSnapshot>('/macro/snapshot')
}
// ── Telegram alerts ─────────────────────────────────────────────────────────
export interface TelegramStatus {
configured: boolean
bot_username: string | null
bound: boolean
wallet_address?: string | null
tg_username?: string | null
chat_id?: number | null
alerts_enabled?: boolean | null
alert_trump?: boolean | null
alert_btc_bottom?: boolean | null
alert_funding?: boolean | null
alert_kol_divergence?: boolean | null
min_confidence?: number | null
mute_from_hour?: number | null
mute_until_hour?: number | null
total_alerts_sent?: number | null
}
export interface TelegramInitResp {
code: string
deep_link: string
expires_in_seconds: number
}
export async function getTelegramStatus(
wallet: string,
env?: SignedEnvelope,
): Promise<TelegramStatus> {
// Pass signature when available so the backend returns full binding details.
// Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation.
if (env) {
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`, signedReadInit(env))
}
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
}
// SignedEnvelope is already imported at the top of this file. Below we
// reuse it directly for the Telegram helpers — no separate alias needed.
export async function tgInit(wallet: string, env: SignedEnvelope): Promise<TelegramInitResp> {
return fetchJson(`/telegram/${wallet}/init`, {
method: 'POST',
body: JSON.stringify(env),
})
}
export async function tgUnbind(wallet: string, env: SignedEnvelope): Promise<{ removed: number }> {
return fetchJson(`/telegram/${wallet}/unbind`, {
method: 'POST',
body: JSON.stringify(env),
})
}
export async function tgTest(wallet: string, env: SignedEnvelope): Promise<{ sent: boolean }> {
return fetchJson(`/telegram/${wallet}/test`, {
method: 'POST',
body: JSON.stringify(env),
})
}
export async function tgPreferences(
wallet: string,
env: SignedEnvelope,
prefs: Partial<{
alerts_enabled: boolean
alert_trump: boolean
alert_btc_bottom: boolean
alert_funding: boolean
alert_kol_divergence: boolean
min_confidence: number
mute_from_hour: number
mute_until_hour: number
}>,
): Promise<TelegramStatus> {
return fetchJson(`/telegram/${wallet}/preferences`, {
method: 'POST',
body: JSON.stringify({ ...env, ...prefs }),
})
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Lightweight in-memory SWR cache for API data.
*
* Lives at module scope — persists across React renders and page navigations
* within the same browser tab, but clears on hard-refresh / tab close.
*
* Usage:
* const data = await swrFetch('posts-500', 3 * 60_000, () => getPosts(500))
*
* On first call → fetches, caches, returns.
* On subsequent calls within TTL → returns cached data immediately.
* On calls after TTL → returns stale data immediately AND triggers a
* background revalidation; the `onUpdate` callback fires when fresh
* data arrives so the component can re-render without a loading flash.
*/
interface CacheEntry<T> {
data: T
ts: number
revalidating: boolean
/** onUpdate callbacks of every caller that hit this key while a background
* revalidation was in flight (M9 fix). All are notified when the fetch
* resolves — previously only the caller that STARTED the revalidation got
* fresh data; everyone else silently kept the stale copy. */
subscribers: Array<(fresh: T) => void>
}
const store = new Map<string, CacheEntry<unknown>>()
export async function swrFetch<T>(
key: string,
ttlMs: number,
fetcher: () => Promise<T>,
onUpdate?: (fresh: T) => void,
): Promise<T> {
const now = Date.now()
const hit = store.get(key) as CacheEntry<T> | undefined
if (hit) {
const age = now - hit.ts
if (age < ttlMs) {
// Still fresh — return immediately.
return hit.data
}
// Stale — return immediately AND revalidate in background. Every caller's
// onUpdate joins the subscriber list; the in-flight fetch notifies all.
if (onUpdate) hit.subscribers.push(onUpdate)
if (!hit.revalidating) {
hit.revalidating = true
fetcher()
.then((fresh) => {
const subs = hit.subscribers
store.set(key, { data: fresh, ts: Date.now(), revalidating: false, subscribers: [] })
for (const notify of subs) {
try {
notify(fresh)
} catch {
/* one bad subscriber must not starve the rest */
}
}
})
.catch(() => {
hit.revalidating = false
hit.subscribers = []
})
}
return hit.data
}
// Cache miss — must wait for the first fetch.
const data = await fetcher()
store.set(key, { data, ts: Date.now(), revalidating: false, subscribers: [] })
return data
}
/** Manually invalidate a cache key (e.g. after a write action). */
export function invalidate(key: string) {
store.delete(key)
}
/** Peek at whether a key has any cached data (stale or fresh). */
export function hasCached(key: string): boolean {
return store.has(key)
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Mobile wallet detection and deep-link helpers.
*
* On mobile browsers (Safari / Chrome) there is no injected `window.ethereum`,
* so the `injected()` wagmi connector returns nothing and the user sees
* "No wallet provider found". The fix is to detect this state early and offer
* deep links that open MetaMask / Trust / Coinbase / OKX and land the user
* inside the wallet's in-app browser at the current dApp URL.
*
* Deep-link patterns used:
* MetaMask https://metamask.app.link/dapp/<host><path> (universal link → iOS/Android)
* Trust https://link.trustwallet.com/open_url?coin_id=60&url=<encoded>
* Coinbase https://go.cb-wallet.com/dapp?url=<encoded>
* OKX https://www.okx.com/download?deeplink=okx%3A%2F%2Fmain%2Fdapp%2Fbrowser%3Furl%3D<encoded>
*
* All links open in a new tab (_blank) so if the user doesn't have the app
* installed they land on the wallet's download page without breaking nav.
*/
/** True when the JS runtime has access to browser APIs. */
const isBrowser = typeof window !== 'undefined'
/**
* Rough mobile UA check — covers iOS (iPhone/iPad) and Android.
* Intentionally excludes desktop Chrome/Firefox even if the viewport is narrow.
*/
export function isMobileDevice(): boolean {
if (!isBrowser) return false
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
}
/**
* Returns true when an injected EIP-1193 provider is present in the window
* (i.e. the user is already inside MetaMask's browser, has a browser extension,
* or Coinbase Wallet's in-app browser).
*/
export function hasInjectedWallet(): boolean {
if (!isBrowser) return false
// window.ethereum is the canonical EIP-1193 injection point.
// Some wallets also inject under their own namespace but all reputable ones
// also set window.ethereum (or window.web3 as a fallback).
return !!(window as unknown as { ethereum?: unknown }).ethereum
}
/**
* True when the user is on mobile AND has no injected wallet — the state
* where we should show the mobile wallet picker instead of a bare error.
*/
export function needsMobileWallet(): boolean {
return isMobileDevice() && !hasInjectedWallet()
}
export interface WalletLink {
name: string
/** Short subtitle shown under the name */
hint: string
/** URL that opens the wallet app and navigates to `dappUrl` */
href: string
/** CSS color for the icon background */
color: string
/** Short letter(s) displayed when no SVG icon is available */
abbr: string
}
/**
* Build the deep-link set for the given dApp URL.
* Pass `window.location.href` (or a canonical URL) as `dappUrl`.
*/
export function getWalletLinks(dappUrl: string): WalletLink[] {
const enc = encodeURIComponent(dappUrl)
// MetaMask universal link: strip the protocol and pass host+path
// e.g. "https://trumpsignal.com/en" → "trumpsignal.com/en"
const hostPath = dappUrl.replace(/^https?:\/\//, '')
return [
{
name: 'MetaMask',
hint: 'Most popular — iOS & Android',
href: `https://metamask.app.link/dapp/${hostPath}`,
color: '#E8831D',
abbr: '🦊',
},
{
name: 'Trust Wallet',
hint: 'Binance-backed, iOS & Android',
href: `https://link.trustwallet.com/open_url?coin_id=60&url=${enc}`,
color: '#3375BB',
abbr: '🛡',
},
{
name: 'Coinbase Wallet',
hint: 'No Coinbase account needed',
href: `https://go.cb-wallet.com/dapp?url=${enc}`,
color: '#1652F0',
abbr: '🔵',
},
{
name: 'OKX Wallet',
hint: 'iOS & Android',
href: `https://www.okx.com/download?deeplink=${encodeURIComponent(`okx://main/dapp/browser?url=${enc}`)}`,
color: '#000000',
abbr: 'OKX',
},
]
}
+10
View File
@@ -0,0 +1,10 @@
import { useRouter } from 'next/router'
export function usePathname(): string {
return useRouter().asPath.split('?')[0]
}
export function useParams(): Record<string, string | string[]> {
return useRouter().query as Record<string, string | string[]>
}
export function useSearchParams(): URLSearchParams {
return new URLSearchParams(useRouter().asPath.split('?')[1] ?? '')
}
+126
View File
@@ -0,0 +1,126 @@
import type { TrumpPost } from '@/types'
import { getPosts, getPostsPage, type PostListResponse } from './api'
const LIVE_ARCHIVE_SOURCES = new Set([
'truth',
'btc_bottom_reversal',
'funding_reversal',
'kol_divergence',
])
function isAiScored(post: Pick<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
}
function buildSourceCounts(items: TrumpPost[]): PostListResponse['source_counts'] {
const counts = new Map<string, { count: number; latest: string | null }>()
for (const post of items) {
const hit = counts.get(post.source)
if (!hit) {
counts.set(post.source, { count: 1, latest: post.published_at })
continue
}
hit.count += 1
if (!hit.latest || post.published_at > hit.latest) hit.latest = post.published_at
}
return Array.from(counts.entries())
.map(([source, meta]) => ({ source, count: meta.count, latest: meta.latest }))
.sort((a, b) => b.count - a.count || a.source.localeCompare(b.source))
}
export function buildPostListFallbackResponse(
items: TrumpPost[],
source: string,
limit: number,
): PostListResponse {
return {
items,
total: items.length,
page: 1,
limit,
counts: {
all: items.length,
actionable: items.filter((p) => p.signal === 'buy' || p.signal === 'short').length,
buy: items.filter((p) => p.signal === 'buy').length,
short: items.filter((p) => p.signal === 'short').length,
off_topic: items.filter((p) => !isAiScored(p)).length,
},
source_counts: [{ source, count: items.length, latest: items[0]?.published_at ?? null }],
}
}
export function buildArchiveFallbackResponse(
allPosts: TrumpPost[],
page: number,
limit: number,
source = 'all',
): PostListResponse {
const archivePosts = allPosts.filter((post) => !LIVE_ARCHIVE_SOURCES.has(post.source))
const sourceCounts = buildSourceCounts(archivePosts)
const filtered =
source === 'all' ? archivePosts : archivePosts.filter((post) => post.source === source)
const offset = (page - 1) * limit
const items = filtered.slice(offset, offset + limit)
return {
items,
total: filtered.length,
page,
limit,
counts: {
all: filtered.length,
actionable: filtered.filter((p) => p.signal === 'buy' || p.signal === 'short').length,
buy: filtered.filter((p) => p.signal === 'buy').length,
short: filtered.filter((p) => p.signal === 'short').length,
off_topic: filtered.filter((p) => !isAiScored(p)).length,
},
source_counts: sourceCounts,
}
}
export async function getInitialPostPage(
limit: number,
page: number,
options: {
source?: string
filters?: {
sourceIn?: string[]
sourceNotIn?: string[]
archiveOnly?: boolean
sentiment?: 'bullish' | 'bearish' | 'neutral'
signal?: 'buy' | 'short' | 'actionable'
aiScoredOnly?: boolean
}
legacyFallbackSource?: string
legacyFallback?: () => Promise<PostListResponse | null>
},
): Promise<PostListResponse | null> {
return getPostsPage(limit, page, options.source, options.filters).catch(async (e) => {
// Only fall back to legacy /posts on 404 (new endpoint not yet deployed).
// Non-404 errors (500, network) should NOT silently mask as empty data —
// a server-side fallback on 500 produces initial HTML that the client
// cannot reproduce on re-fetch (it shows an error instead), causing a
// hydration mismatch. Return null so the page renders empty and the
// client fetches cleanly on mount.
const detail = e instanceof Error ? e.message : ''
if (!detail.includes('404')) return null
if (options.legacyFallback) {
return options.legacyFallback()
}
if (
!options.legacyFallbackSource ||
options.filters?.archiveOnly ||
options.filters?.sourceIn ||
options.filters?.sourceNotIn
) {
return null
}
const fallbackItems = await getPosts(limit, page, options.legacyFallbackSource).catch(
() => null,
)
return fallbackItems
? buildPostListFallbackResponse(fallbackItems, options.legacyFallbackSource, limit)
: null
})
}
+74
View File
@@ -0,0 +1,74 @@
export { bodyHash, buildSignMessage, type SignedEnvelope } from '@trumpsignal/contracts/signing'
import { buildSignMessage, type SignedEnvelope } from '@trumpsignal/contracts/signing'
/**
* Produce {timestamp, signature} for a given action + optional body.
* Caller supplies wagmi's `signMessageAsync`.
*/
/**
* Get-or-create a cached "view" envelope for a wallet. Reused across page loads
* within a short window below the server's 5-minute skew allowance.
* Backend's replay-guard is disabled for the view action so the same sig can
* be used multiple times while it remains fresh.
*/
const VIEW_TTL_MS = 4 * 60 * 1000 // 4 min — stays safely inside backend's 5 min skew window
/**
* Read-only: returns a cached, not-yet-expired view envelope if one exists.
* Never signs — never triggers a wallet popup. Returns null if no fresh cache.
*/
export function getCachedViewEnvelope(action: string, wallet: string): SignedEnvelope | null {
if (typeof window === 'undefined') return null
const cacheKey = `ts:view:${action}:${wallet.toLowerCase()}`
const raw = sessionStorage.getItem(cacheKey)
if (!raw) return null
try {
const env = JSON.parse(raw) as SignedEnvelope
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
sessionStorage.removeItem(cacheKey)
} catch (err) {
console.warn('[signedRequest] corrupt cached envelope, ignoring', err)
sessionStorage.removeItem(cacheKey)
}
return null
}
export async function getOrCreateViewEnvelope(params: {
action: string
wallet: string
signMessageAsync: (args: { message: string }) => Promise<string>
}): Promise<SignedEnvelope> {
if (typeof window === 'undefined') throw new Error('view envelope requires browser')
const wallet = params.wallet.toLowerCase()
const cacheKey = `ts:view:${params.action}:${wallet}`
const raw = sessionStorage.getItem(cacheKey)
if (raw) {
try {
const env = JSON.parse(raw) as SignedEnvelope
if (Date.now() - env.timestamp < VIEW_TTL_MS) return env
} catch (err) {
console.warn('[signedRequest] corrupt cached envelope, ignoring', err)
sessionStorage.removeItem(cacheKey)
}
}
const env = await signRequest({
action: params.action,
wallet,
body: null,
signMessageAsync: params.signMessageAsync,
})
sessionStorage.setItem(cacheKey, JSON.stringify(env))
return env
}
export async function signRequest(params: {
action: string
wallet: string
body: unknown | null
signMessageAsync: (args: { message: string }) => Promise<string>
}): Promise<SignedEnvelope> {
const timestamp = Date.now()
const message = await buildSignMessage(params.action, params.wallet, timestamp, params.body)
const signature = await params.signMessageAsync({ message })
return { wallet: params.wallet.toLowerCase(), timestamp, signature }
}
+94
View File
@@ -0,0 +1,94 @@
import type { Metadata } from 'next'
const siteTitle = 'Trump Alpha'
const siteTagline = 'The crypto signals that move price before the crowd'
export const siteDescription =
"Endorphin is a crypto research desk tracking six signals that move markets before consensus catches up — Trump's posts, BTC macro bottoms, funding-rate extremes, KOL long-form calls, talks-vs-trades divergence, and the Breakout Monitor. Every signal is public and timestamped."
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
export const siteMetadata: Metadata = {
metadataBase: siteUrl ? new URL(siteUrl) : new URL('https://trumpsignal.com'),
title: {
default: `${siteTitle} | ${siteTagline}`,
template: `%s | ${siteTitle}`,
},
description: siteDescription,
applicationName: siteTitle,
keywords: [
'Trump Alpha',
'TrumpSignal',
'crypto signals',
'crypto alpha dashboard',
'Macro Vibes',
'crypto macro dashboard',
'BTC bottom reversal',
'Bitcoin macro bottom',
'AHR999',
'Pi Cycle Bottom',
'KOL crypto signals',
'Arthur Hayes signals',
'Delphi Digital',
'Bankless',
'KOL on-chain tracking',
'talks vs trades divergence',
'smart-money divergence',
'AI trading dashboard',
'Trump Truth Social signal',
'Hyperliquid trading bot',
'crypto auto-trader',
'crypto intelligence platform',
'funding rate reversal',
'Bitcoin cycle bottom',
],
authors: [{ name: 'Endorphin' }],
creator: 'Endorphin',
publisher: 'Endorphin',
category: 'Finance',
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
alternates: {
canonical: siteUrl || 'https://trumpsignal.com',
languages: {
en: `${siteUrl || 'https://trumpsignal.com'}/en`,
'x-default': siteUrl || 'https://trumpsignal.com',
},
},
openGraph: {
title: `${siteTitle} | ${siteTagline}`,
description: siteDescription,
siteName: siteTitle,
type: 'website',
locale: 'en_US',
url: siteUrl || 'https://trumpsignal.com',
images: [
{
url: '/opengraph-image',
width: 1200,
height: 630,
alt: `${siteTitle} — ${siteTagline}`,
type: 'image/png',
},
],
},
twitter: {
card: 'summary_large_image',
title: `${siteTitle} | ${siteTagline}`,
description: siteDescription,
images: ['/opengraph-image'],
},
verification: {
// Google Search Console: paste your verification token from
// https://search.google.com/search-console → Add property → HTML tag
// google: 'YOUR_GOOGLE_VERIFICATION_TOKEN',
// Bing Webmaster Tools (also covers DuckDuckGo/Yahoo):
// other: { 'msvalidate.01': 'YOUR_BING_VERIFICATION_TOKEN' },
},
}
+327
View File
@@ -0,0 +1,327 @@
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
import { siteDescription } from './siteMetadata'
;('Endorphin is a crypto research desk tracking public, timestamped market signals.')
const _base = siteUrl || 'https://trumpsignal.com'
const _today = new Date().toISOString().slice(0, 10)
export const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
// ── 1. WebSite — enables sitelinks search box in Google results ──────────
{
'@type': 'WebSite',
'@id': `${_base}/#website`,
name: 'Trump Alpha',
url: _base,
description: siteDescription,
inLanguage: 'en-US',
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${_base}/en/trump?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
},
// ── 2. SoftwareApplication — core product entity ─────────────────────────
{
'@type': 'SoftwareApplication',
'@id': `${_base}/#app`,
name: 'Trump Alpha',
alternateName: ['TrumpSignal', 'Trump Alpha crypto', 'Trump crypto bot'],
url: _base,
applicationCategory: 'FinanceApplication',
applicationSubCategory: 'Cryptocurrency Trading Tool',
operatingSystem: 'Web',
description: siteDescription,
publisher: { '@id': `${_base}/#org` },
author: { '@id': `${_base}/#org` },
datePublished: '2025-01-01',
dateModified: _today,
inLanguage: ['en'],
isAccessibleForFree: true,
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
description:
'Free to read all signals. Bring your own Hyperliquid account for auto-trading.',
},
featureList: [
'Real-time Trump Truth Social sentiment scoring — post to signal in under 3 seconds',
'BTC macro-bottom confluence detection (AHR999 + 200-week MA + Pi Cycle Bottom, 2-of-3)',
'KOL Substack and podcast signal extraction with AI conviction scoring',
'Talks-vs-trades divergence detection: on-chain wallet vs public statements',
'BTC perpetual funding-rate reversal detection',
'Hyperliquid auto-trader integration with isolated margin, TP/SL, and daily budget cap',
],
screenshot: `${_base}/opengraph-image`,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: '4.8',
ratingCount: '42',
bestRating: '5',
},
},
// ── 3. Organization ───────────────────────────────────────────────────────
{
'@type': 'Organization',
'@id': `${_base}/#org`,
name: 'Endorphin',
alternateName: ['Endorphin Research', 'Endorphin Desk'],
url: _base,
logo: `${_base}/icon`,
description:
'Endorphin is a crypto research desk. Trump Alpha is its public signal project — four market-moving signals tracked live and timestamped.',
brand: {
'@type': 'Brand',
name: 'Trump Alpha',
},
contactPoint: {
'@type': 'ContactPoint',
email: 'support@bitnews.day',
contactType: 'customer support',
availableLanguage: ['English'],
},
},
// ── 4. HowTo — "How to set up Trump Alpha auto-trader" ───────────────────
// Structured HowTo gets its own rich result in Google and is cited by
// AI assistants answering "how to auto-trade trump tweets" questions.
{
'@type': 'HowTo',
name: 'How to set up Trump Alpha auto-trader on Hyperliquid',
description:
'Step-by-step guide to connect your Hyperliquid account and enable automatic crypto trading based on Trump Truth Social posts.',
totalTime: 'PT10M',
estimatedCost: { '@type': 'MonetaryAmount', currency: 'USD', value: '0' },
tool: [
{ '@type': 'HowToTool', name: 'Hyperliquid account (app.hyperliquid.xyz)' },
{ '@type': 'HowToTool', name: 'MetaMask or any injected Ethereum wallet' },
],
step: [
{
'@type': 'HowToStep',
position: 1,
name: 'Create a Hyperliquid account',
text: 'Visit app.hyperliquid.xyz and create an account. Fund it with at least the minimum position size you want to trade.',
},
{
'@type': 'HowToStep',
position: 2,
name: 'Generate a trade-only API key',
text: 'In Hyperliquid settings, generate an API key with trade-only permissions. This key can open and close positions but cannot withdraw funds.',
},
{
'@type': 'HowToStep',
position: 3,
name: 'Connect your wallet on Trump Alpha',
text: 'Visit trumpsignal.com/en/settings and click Connect Wallet. Use MetaMask or any injected Ethereum wallet — no transaction or gas is needed for read-only connections.',
},
{
'@type': 'HowToStep',
position: 4,
name: 'Subscribe and paste your API key',
text: 'Click Subscribe on the Settings page, then paste your Hyperliquid API key. The key is encrypted server-side and never stored in your browser.',
},
{
'@type': 'HowToStep',
position: 5,
name: 'Configure your trading parameters',
text: 'Set your leverage (default 3×), position size in USD, take-profit and stop-loss percentages, daily budget cap, and active trading hours.',
},
{
'@type': 'HowToStep',
position: 6,
name: 'Enable Auto-Trade',
text: 'On the Trump page, flip the Auto-Trade switch to ON. The bot will now open isolated-margin trades on Hyperliquid when Trump posts score above your confidence threshold.',
},
],
},
// ── 5. DefinedTermSet — glossary entities for AI citation ────────────────
// LLMs use DefinedTermSet to learn entity definitions. When someone asks
// ChatGPT/Perplexity "what is AHR999", this makes Trump Alpha citable.
{
'@type': 'DefinedTermSet',
'@id': `${_base}/en/glossary#termset`,
name: 'Trump Alpha Crypto Glossary',
url: `${_base}/en/glossary`,
description:
'Definitions of key terms, indicators, and concepts used in the Trump Alpha crypto intelligence platform.',
hasDefinedTerm: [
{
'@type': 'DefinedTerm',
name: 'AHR999',
description:
'A Bitcoin valuation indicator combining price, the geometric mean of historical price, and an exponential growth model. Values below 0.45 mark the deep-value accumulation zone used in the Trump Alpha BTC bottom scanner.',
inDefinedTermSet: `${_base}/en/glossary#termset`,
},
{
'@type': 'DefinedTerm',
name: 'Pi Cycle Bottom',
description:
'A Bitcoin bottom indicator: 150-day EMA ≤ 471-day SMA × 0.745. Has historically pinpointed BTC cycle bottoms within a 2-week window in 2015, 2018, and 2022.',
inDefinedTermSet: `${_base}/en/glossary#termset`,
},
{
'@type': 'DefinedTerm',
name: 'Talks-vs-Trades Divergence',
description:
"When a crypto KOL's public verbal stance and their on-chain wallet behavior point in opposite directions on the same asset within a ±7-day window. Treated as the highest-conviction signal category on Trump Alpha.",
inDefinedTermSet: `${_base}/en/glossary#termset`,
},
{
'@type': 'DefinedTerm',
name: 'Funding Rate Reversal',
description:
'A mean-reversion signal triggered when 30-day cumulative BTC perp funding crosses ±3% AND the most recent cycles start cooling — a bet against the crowded side of positioning.',
inDefinedTermSet: `${_base}/en/glossary#termset`,
},
],
},
// ── 6. FAQPage — expanded with search-intent questions ───────────────────
{
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'What is Trump Alpha?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Trump Alpha is an AI-powered crypto intelligence dashboard that monitors six signal sources: Trump Truth Social posts, BTC macro-bottom confluence signals, KOL Substack and podcast essays, talks-vs-trades divergence between public commentary and on-chain behavior, a funding-rate reversal trigger, and a real-time breakout monitor for ETH and LINK.',
},
},
{
'@type': 'Question',
name: 'How does the Trump signal engine work?',
acceptedAnswer: {
'@type': 'Answer',
text: "The engine scrapes Trump's Truth Social feed continuously. Each post is sent to an AI model that classifies it as LONG (bullish crypto), SHORT (bearish), or NOISE (off-topic). If the confidence score exceeds your threshold, the engine optionally places an isolated-margin trade on Hyperliquid. The entire pipeline runs in under 3 seconds from post to position.",
},
},
{
'@type': 'Question',
name: 'What is Macro Vibes?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Macro Vibes is the macro-regime dashboard for crypto. It surfaces eight free, public macro signals in one view — AHR999, Fear & Greed, BTC dominance, ETH/BTC ratio, total stablecoin supply, US spot Bitcoin ETF daily net flow, BTC perp open interest, and the Altcoin Season Index — plus a long-only BTC bottom-reversal trigger (2-of-3 confluence of AHR999, 200-week MA, and Pi Cycle Bottom) and a BTC perp funding-rate reversal trigger. One screen tells you whether the macro tape is bullish, bearish, or neutral.',
},
},
{
'@type': 'Question',
name: 'What is talks-vs-trades divergence?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Talks-vs-trades divergence compares what a crypto KOL says publicly (e.g., "I\'m bullish on ETH") against what their on-chain wallet actually does within a 7-day window. When a KOL is publicly bullish but their wallet is selling, Trump Alpha flags it as a divergence signal — the on-chain action is treated as ground truth, making the divergence one of the highest-conviction alpha signals on the platform.',
},
},
{
'@type': 'Question',
name: 'Which KOLs does Trump Alpha track?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Trump Alpha tracks 25 KOL feeds including Arthur Hayes (BitMEX), Delphi Digital, Pomp, and major crypto podcasts (Empire, Bankless, Unchained, 0xResearch, Lightspeed). Posts and episodes are processed daily; AI extracts ticker calls, direction (bullish/bearish/buy/sell), and conviction scores.',
},
},
{
'@type': 'Question',
name: 'Is Trump Alpha free?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. All six signal dashboards are free to read. The optional Hyperliquid auto-trader requires a subscription, your own Hyperliquid account, and an API key. Trump Alpha never takes custody of your funds — your API key can open and close positions but cannot withdraw.',
},
},
{
'@type': 'Question',
name: 'How do I set up the Hyperliquid auto-trader?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Create a Hyperliquid account at app.hyperliquid.xyz, generate an API key with trade-only permissions (no withdrawal), and paste it into the Trump Alpha settings page. Configure your leverage (default 3×), position size, TP/SL levels, daily budget cap, and active trading hours. The bot only trades when all your conditions are met.',
},
},
{
'@type': 'Question',
name: 'What safety features does the auto-trader have?',
acceptedAnswer: {
'@type': 'Answer',
text: 'The Trump Alpha auto-trader has six safety layers: (1) minimum confidence threshold before any trade fires, (2) isolated margin so a bad trade cannot drain other positions, (3) automatic take-profit and stop-loss on every open position, (4) daily budget cap — it stops trading once the cap is hit, (5) active hours window so it only trades when you choose, (6) per-trade position size cap.',
},
},
{
'@type': 'Question',
name: 'What is the BTC macro-bottom scanner looking for?',
acceptedAnswer: {
'@type': 'Answer',
text: 'It is looking for rare macro-bottom environments rather than trying to pick intraday reversals. The live signal requires 2-of-3 agreement across AHR999, the 200-week moving average, and Pi Cycle Bottom, then manages the position with staged de-risking, profit-floor ratchets, and an 18-month backstop.',
},
},
{
'@type': 'Question',
name: 'Does Trump Alpha store my trading keys?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Your Hyperliquid API key is encrypted and stored securely on the Trump Alpha server — it never sits in your browser. The key is scoped to trade-only permissions and cannot withdraw funds. We never ask for your MetaMask seed phrase or any wallet private key.',
},
},
// Search-intent questions — people actually search for these phrases
{
'@type': 'Question',
name: 'How do I auto trade Trump tweets for crypto?',
acceptedAnswer: {
'@type': 'Answer',
text: "Trump Alpha monitors Trump's Truth Social feed in real time and classifies each post as LONG (crypto bullish), SHORT (bearish), or NOISE. Posts scoring above your confidence threshold automatically trigger an isolated-margin trade on Hyperliquid within 3 seconds. Setup takes about 10 minutes: create a Hyperliquid account, generate a trade-only API key, connect your wallet at trumpsignal.com/en/settings, and enable Auto-Trade.",
},
},
{
'@type': 'Question',
name: 'Is there a bot that trades crypto based on Trump posts?',
acceptedAnswer: {
'@type': 'Answer',
text: "Yes. Trump Alpha is a non-custodial crypto bot that classifies Trump's Truth Social posts with AI and executes trades on Hyperliquid automatically. It uses trade-only API keys (no withdrawal permission), isolated margin on every position, and configurable safety parameters including stop-loss, daily budget cap, and active trading hours.",
},
},
{
'@type': 'Question',
name: 'What crypto signals do KOL newsletters give?',
acceptedAnswer: {
'@type': 'Answer',
text: "Trump Alpha ingests 25 crypto KOL newsletters, blogs, and podcast feeds daily — including Arthur Hayes, Delphi Digital, Bankless, and Unchained. AI extracts explicit asset calls (buy/sell/bullish/bearish), conviction scores, and supporting quotes. The platform then cross-references these public stances against each KOL's on-chain wallet activity to surface divergence signals.",
},
},
{
'@type': 'Question',
name: 'How do I know if a crypto KOL is dumping while shilling?',
acceptedAnswer: {
'@type': 'Answer',
text: "Trump Alpha's talks-vs-trades divergence module cross-references what a KOL says publicly in their Substack or podcast against what their on-chain Ethereum wallet actually does within the same ±7-day window. When a KOL is publicly bullish but their wallet is selling, Trump Alpha flags it as a divergence — the highest-conviction signal category on the platform.",
},
},
{
'@type': 'Question',
name: 'When is the best time to buy Bitcoin in a bear market?',
acceptedAnswer: {
'@type': 'Answer',
text: "Trump Alpha's Macro Vibes module watches three classic Bitcoin cycle-bottom indicators: AHR999 below 0.45, price at or below the 200-week moving average × 1.05, and the Pi Cycle Bottom signal. When at least 2 of these 3 agree, the platform fires a rare long-only signal — historically this 2-of-3 confluence has appeared only 2–4 times per market cycle, near genuine macro bottoms.",
},
},
{
'@type': 'Question',
name: 'What is AHR999 and how is it used in crypto?',
acceptedAnswer: {
'@type': 'Answer',
text: 'AHR999 is a Bitcoin valuation model combining long-term price trends and a cost-basis anchor. A value below 0.45 historically marks the deep-value accumulation zone. Trump Alpha uses AHR999 as one of three inputs in its BTC macro-bottom scanner — the signal fires when AHR999 < 0.45, BTC price ≤ 200-week MA × 1.05, and Pi Cycle Bottom conditions are met, requiring at least 2 of these 3 to agree.',
},
},
{
'@type': 'Question',
name: 'What is Bitcoin perpetual funding rate and why does it matter?',
acceptedAnswer: {
'@type': 'Answer',
text: "Perpetual futures funding rate is the periodic payment between longs and shorts that keeps the contract price anchored to spot. Extreme positive funding means longs are crowded and paying shorts; extreme negative means shorts are crowded. Trump Alpha's funding reversal scanner monitors 30-day cumulative funding and fires a mean-reversion signal when it crosses ±3% AND the most recent cycles start cooling.",
},
},
],
},
],
}
+29
View File
@@ -0,0 +1,29 @@
'use client'
import { useWsSubscribe } from './wsContext'
interface Handlers {
// BUG-08 fix: backend now broadcasts SOL/TRUMP/BNB/DOGE/LINK/AAVE ticks
// in addition to BTC/ETH — widen asset to string.
onPrice?: (asset: string, price: number) => void
onNewPost?: (post: object) => void
}
type PriceMsg = { type: 'price'; asset: string; price: number }
type PostMsg = { type: 'new_post'; post: object }
/**
* Subscribe to price ticks and new-post events from the shared WebSocket.
* Delegates to WsProvider's singleton connection — no new socket is opened.
*/
export function usePriceSocket(handlers: Handlers) {
useWsSubscribe('price', (msg) => {
const m = msg as PriceMsg
handlers.onPrice?.(m.asset, m.price)
})
useWsSubscribe('new_post', (msg) => {
const m = msg as PostMsg
handlers.onNewPost?.(m.post)
})
}
+33
View File
@@ -0,0 +1,33 @@
export function formatPrice(n: number): string {
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function formatPct(n: number): string {
const sign = n >= 0 ? '+' : ''
return `${sign}${n.toFixed(2)}%`
}
export function formatHold(seconds: number): string {
if (seconds < 60) return `${seconds}s`
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h === 0) return `${m}m`
if (m === 0) return `${h}h`
return `${h}h ${m}m`
}
export function shortenAddress(addr: string): string {
if (addr.length < 10) return addr
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
}
export function sentimentColor(s: string): string {
switch (s) {
case 'bullish':
return '#4ade80'
case 'bearish':
return '#ef4444'
default:
return '#555555'
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createConfig, createStorage, http } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { injected, walletConnect } from 'wagmi/connectors'
export const chains = [mainnet] as const
// WalletConnect v2 requires a free project ID from https://cloud.walletconnect.com
// Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID in .env.local to enable:
// - QR code scanning (desktop → mobile wallet)
// - All WalletConnect-compatible mobile wallets
// Without it, only injected (browser extension) wallets are available.
const wcProjectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
export const config = createConfig({
chains,
connectors: [
// Generic injected connector — NOT pinned to target:'metaMask'. Pinning to
// MetaMask made every other injected wallet (Rabby, Coinbase Wallet ext,
// Brave, Frame, Trust, OKX…) fail to connect, and broke in browsers where
// window.ethereum isn't MetaMask. wagmi v2 auto-discovers all injected
// wallets via EIP-6963 (multiInjectedProviderDiscovery, on by default), so
// a generic injected() connector covers them.
injected({ shimDisconnect: false }),
// WalletConnect v2 — enabled when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is set.
// Provides QR-code pairing (desktop ↔ mobile) and all WalletConnect wallets.
// Get a free project ID at https://cloud.walletconnect.com
...(wcProjectId
? [
walletConnect({
projectId: wcProjectId,
metadata: {
name: 'Trump Alpha',
description: 'Live crypto signals — Trump posts, BTC macro, KOL divergence',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com',
icons: ['https://trumpsignal.com/icon'],
},
showQrModal: true,
}),
]
: []),
],
transports: {
[mainnet.id]: http(),
},
// sessionStorage: clears on tab close, prevents stale auto-reconnect across sessions.
// This means MetaMask only pops when the user explicitly clicks "Connect wallet".
storage:
typeof window !== 'undefined' ? createStorage({ storage: window.sessionStorage }) : undefined,
// Disable silent auto-reconnect on page mount.
// Without this, wagmi checks localStorage/sessionStorage for a saved connector
// and tries to reconnect MetaMask silently — which can pop the MetaMask window.
// User must explicitly click "Connect wallet" each session.
ssr: true,
})
+25
View File
@@ -0,0 +1,25 @@
import type { Connector } from 'wagmi'
import { walletErrorLabel } from '@/lib/walletError'
export function walletConnectErrorLabel(err: unknown): string {
const msg = walletErrorLabel(err, 'Cancelled', 140)
if (/Provider not found/i.test(msg)) {
return 'No wallet provider found in this browser. Open Trump Alpha in a wallet-enabled browser.'
}
return msg
}
export async function getFirstReadyConnector(
connectors: readonly Connector[],
): Promise<Connector | null> {
for (const connector of connectors) {
try {
const provider = await connector.getProvider?.()
if (provider) return connector
} catch {
// Keep scanning — some connectors throw while probing availability.
}
}
return connectors[0] ?? null
}
+42
View File
@@ -0,0 +1,42 @@
/**
* MetaMask / EIP-1193 wallet error helpers.
*
* Previously the codebase detected user rejections by string-matching the
* error message for "reject" or "denied". That breaks when MetaMask's UI
* language is set to anything other than English (the message is translated
* but our match string isn't). The reliable signal is the EIP-1193 error
* code: 4001 = "User rejected the request".
*
* Wagmi sometimes wraps the underlying provider error inside `.cause`, so we
* walk that chain too.
*/
interface WithCode {
code?: number | string
cause?: unknown
}
export function isUserRejection(err: unknown): boolean {
// Walk a small depth of .cause chains since wagmi/viem wrap provider errors
let cur: unknown = err
for (let i = 0; i < 4 && cur; i++) {
const c = (cur as WithCode)?.code
if (c === 4001 || c === 'ACTION_REJECTED' || c === 'USER_REJECTED') return true
cur = (cur as WithCode)?.cause
}
// Last-resort string fallback for providers that don't expose a code field.
// Kept narrow — "reject" + "denied" + a couple of locale variants we've
// actually seen from MetaMask zh-CN ("拒绝") and Rabby ("rejected by user").
const m = err instanceof Error ? err.message : ''
return /reject|denied|cancell|拒绝|已取消/i.test(m)
}
/**
* Convenience: return either a friendly cancellation label or a truncated
* version of the real error message — never raw user-facing crash text.
*/
export function walletErrorLabel(err: unknown, cancelledLabel = 'Cancelled', maxLen = 110): string {
if (isUserRejection(err)) return cancelledLabel
const m = err instanceof Error ? err.message : String(err ?? '')
return m.slice(0, maxLen) || 'unknown error'
}
+145
View File
@@ -0,0 +1,145 @@
'use client'
/**
* Singleton WebSocket provider.
*
* The entire app shares ONE connection to /ws/prices. Components subscribe
* to specific message types via `useWsSubscribe` — no component owns the socket.
*
* Why: previously DashboardClient (via usePriceSocket) and SignalMonitor each
* opened their own WebSocket, giving the server two connections per page load
* and causing each component to receive every broadcast twice.
*/
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react'
type Handler = (msg: unknown) => void
interface WsContextValue {
subscribe: (type: string, fn: Handler) => () => void
}
const WsContext = createContext<WsContextValue | null>(null)
// In production, NEXT_PUBLIC_WS_URL must be set (e.g. wss://api.trumpsignal.com).
// Fallback auto-upgrades ws→wss when the page is served over HTTPS to avoid
// mixed-content blocks, and falls back to ws:// for local dev.
const _wsEnv = process.env.NEXT_PUBLIC_WS_URL
const WS_URL =
_wsEnv ||
(typeof window !== 'undefined' && window.location.protocol === 'https:'
? 'wss://localhost:8000'
: 'ws://localhost:8000')
// Exponential backoff: 2s → 4s → 8s → … capped at 60s, plus ±30% jitter
// so a server restart doesn't get hit by all clients at the same instant.
const WS_RETRY_BASE_MS = 2_000
const WS_RETRY_MAX_MS = 60_000
function retryDelay(attempt: number): number {
const exp = Math.min(WS_RETRY_BASE_MS * 2 ** attempt, WS_RETRY_MAX_MS)
return exp * (0.7 + Math.random() * 0.6) // ±30% jitter
}
export function WsProvider({ children }: { children: ReactNode }) {
// Stable map: message-type → set of handlers. Never replaced, only mutated.
const subs = useRef<Map<string, Set<Handler>>>(new Map())
useEffect(() => {
let dead = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
let attempt = 0
let socket: WebSocket | null = null
function connect() {
if (dead) return
socket = new WebSocket(`${WS_URL}/ws/prices`)
const ws = socket // local alias for handler closures
ws.onopen = () => {
attempt = 0 // reset backoff on successful connection
}
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data) as { type?: string }
const t = msg.type ?? '__unknown__'
subs.current.get(t)?.forEach((fn) => fn(msg))
} catch {
// ignore malformed frames
}
}
ws.onclose = () => {
if (!dead) {
const delay = retryDelay(attempt++)
retryTimer = setTimeout(connect, delay)
}
}
ws.onerror = () => ws.close()
}
// Reconnect immediately when the tab comes back to the foreground
// (the socket may have silently died while the tab was hidden).
function onVisible() {
if (document.visibilityState !== 'visible') return
if (socket && socket.readyState === WebSocket.OPEN) return
// Cancel any pending retry — we're reconnecting now.
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
attempt = 0
if (socket && socket.readyState < WebSocket.CLOSING) {
socket.onclose = null
socket.close()
}
connect()
}
document.addEventListener('visibilitychange', onVisible)
connect()
return () => {
dead = true
document.removeEventListener('visibilitychange', onVisible)
if (retryTimer) clearTimeout(retryTimer)
// Actively close any live socket so the OS-level connection releases
// immediately on unmount instead of waiting for the next server keepalive.
if (socket && socket.readyState <= WebSocket.OPEN) {
socket.onclose = null // suppress the auto-reconnect we'd otherwise queue
socket.close()
}
socket = null
}
}, [])
function subscribe(type: string, fn: Handler): () => void {
if (!subs.current.has(type)) subs.current.set(type, new Set())
subs.current.get(type)!.add(fn)
return () => subs.current.get(type)?.delete(fn)
}
return <WsContext.Provider value={{ subscribe }}>{children}</WsContext.Provider>
}
/**
* Subscribe to a specific WebSocket message type from the shared connection.
*
* @param type The `msg.type` string to listen for (e.g. 'price', 'new_post', 'funding_signal')
* @param handler Called with the full parsed message object on every matching frame.
* Always receives the latest handler reference — no stale-closure issues.
*
* Usage:
* useWsSubscribe('price', (msg) => { ... })
*/
export function useWsSubscribe(type: string, handler: Handler): void {
const ctx = useContext(WsContext)
// Keep a ref so the subscription closure never goes stale even if handler
// is an inline function that changes every render.
const handlerRef = useRef<Handler>(handler)
handlerRef.current = handler
useEffect(() => {
if (!ctx) return
return ctx.subscribe(type, (msg) => handlerRef.current(msg))
}, [ctx, type]) // type is stable in practice; ctx never changes
}
+28
View File
@@ -0,0 +1,28 @@
{
"nav": {
"brand": "Trump Alpha",
"tabs": {
"overview": "Overview",
"trump": "Trump",
"vibes": "Vibes",
"kol": "KOL",
"trades": "Trades",
"analytics": "Analytics",
"settings": "Config"
},
"actions": {
"connectWallet": "Connect wallet",
"copyAddress": "Copy address",
"copied": "Copied",
"disconnect": "Disconnect"
}
},
"footer": {
"methodology": "Methodology",
"glossary": "Glossary",
"caseStudies": "Case Studies",
"privacy": "Privacy",
"terms": "Terms",
"contact": "Contact"
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"nav": {
"brand": "Trump Alpha",
"tabs": {
"overview": "总览",
"trump": "Trump",
"vibes": "Vibes",
"kol": "KOL",
"trades": "交易",
"analytics": "分析",
"settings": "设置"
},
"actions": {
"connectWallet": "连接钱包",
"copyAddress": "复制地址",
"copied": "已复制",
"disconnect": "断开连接"
}
},
"footer": {
"methodology": "方法论",
"glossary": "术语表",
"caseStudies": "案例研究",
"privacy": "隐私政策",
"terms": "服务条款",
"contact": "联系我们"
}
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+23
View File
@@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
export default {
reactStrictMode: true,
async rewrites() {
return [
'icon',
'apple-icon',
'opengraph-image',
'robots.txt',
'sitemap.xml',
'manifest.webmanifest',
].map((name) => ({ source: '/' + name, destination: '/api/' + name.split('.')[0] }))
},
transpilePackages: ['@trumpsignal/ui', '@trumpsignal/contracts'],
webpack(config) {
config.resolve.fallback = {
...config.resolve.fallback,
'@react-native-async-storage/async-storage': false,
'pino-pretty': false,
}
return config
},
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@trumpsignal/web",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"lint": "eslint .",
"verify": "npm run lint && npm run build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@rainbow-me/rainbowkit": "2.2.11",
"@tanstack/react-query": "5.99.2",
"lightweight-charts": "4.2.3",
"next": "16.2.6",
"next-intl": "4.12.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"viem": "2.50.4",
"wagmi": "2.19.5",
"zustand": "4.5.7",
"@trumpsignal/ui": "*",
"@trumpsignal/contracts": "*"
},
"devDependencies": {
"@types/node": "20.19.39",
"@types/react": "18.3.28",
"@types/react-dom": "18.3.7",
"autoprefixer": "10.5.0",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"postcss": "8.5.10",
"tailwindcss": "3.4.19",
"typescript": "5.9.3"
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from 'next'
import AnalyticsPageClient from '@/screens/analytics/AnalyticsPageClient'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
async function generateMetadata(): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '交易分析与表现' : 'Trading Analytics & Performance'
const description = isZh
? '查看胜率、最大回撤、平均持仓时间、信号准确率和历史交易表现,评估 Trump Alpha 的真实执行质量。'
: 'Review win rate, drawdown, average hold time, signal accuracy, and historical trade performance to evaluate live execution quality.'
return {
title,
description,
openGraph: { title, description },
alternates: {
canonical: `${siteUrl}/en/analytics`,
languages: {
en: `${siteUrl}/en/analytics`,
},
},
}
}
export default function AnalyticsPage() {
return (
<>
<Breadcrumbs items={[{ name: 'Analytics', path: '/en/analytics' }]} />
<AnalyticsPageClient />
</>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: await generateMetadata() } }
}
+45
View File
@@ -0,0 +1,45 @@
// Archive is legacy/test data only — not part of the live signal stack.
// noindex keeps it out of search results (duplicate/thin content risk)
// while keeping it accessible to logged-in users for inspection.
import type { Metadata } from 'next'
import { type PostListResponse } from '@/lib/api'
import { getPosts } from '@/lib/api'
import { buildArchiveFallbackResponse, getInitialPostPage } from '@/lib/postPage'
import ArchivePageClient from '@/screens/archive/ArchivePageClient'
const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function ArchivePage({ initialData }: Awaited<ReturnType<typeof loadData>>) {
return <ArchivePageClient initialData={initialData} />
}
async function loadData() {
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
filters: { archiveOnly: true },
legacyFallback: async () => {
// Old backend (no /posts-paged archive_only): /posts caps at 500 per
// page, so a single page only sees the latest 500 posts globally — older
// archive rows get buried under fresh truth posts and silently dropped.
// Page through a few windows so archive coverage isn't truncated.
const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows
const collected: Awaited<ReturnType<typeof getPosts>> = []
for (let p = 1; p <= MAX_PAGES; p++) {
const batch = await getPosts(500, p).catch(() => null)
if (!batch || batch.length === 0) break
collected.push(...batch)
if (batch.length < 500) break // last page reached
}
return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null
},
})
return { initialData }
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
context.res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60')
return { props: { ...(await loadData()), seo: metadata } }
}
+7
View File
@@ -0,0 +1,7 @@
export default function Redirect() {
return null
}
export const getServerSideProps: import('next').GetServerSideProps = async ({ params }) =>
params?.locale !== 'en'
? { notFound: true }
: { redirect: { destination: '/en/macro', permanent: true } }
+530
View File
@@ -0,0 +1,530 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface CaseStudy {
id: string
date: string
source: string
title: string
summary: string
signal: 'LONG' | 'SHORT' | 'DIVERGENCE'
asset: string
priceImpact: string
impactWindow: string
detail: string
evidence: string
externalUrl?: string
}
type CaseStudiesCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
statAsset: string
statImpact: string
statWindow: string
evidenceLabel: string
footer: string
footerMethodology: string
footerGlossary: string
cases: CaseStudy[]
}
function getCopy(locale: string): CaseStudiesCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '案例研究 - Trump Alpha',
description:
'整理 Trump Truth Social 帖子如何影响比特币与加密市场的公开案例,包括战略储备公告、链上大额抢跑交易、风险偏好切换以及 KOL 言行偏离样本。',
keywords: [
'Trump 加密市场影响',
'Truth Social 比特币',
'Trump 帖子行情',
'加密市场案例研究',
'KOL 言行偏离案例',
'BTC 底部案例',
],
ogTitle: 'Case Studies | Trump Alpha',
ogDescription: '记录过往真正推动市场的帖子、仓位和链上证据,解释这些信号为什么值得跟踪。',
heroTitle: '案例研究',
heroSubtitle: '把历史上真正推动市场的帖子、仓位和价格反应摊开来看',
intro:
'这不是收益展示页,而是证据页。每个案例都尽量回答三个问题:发生了什么、市场怎么动、为什么这件事支持某个信号引擎的存在。',
statAsset: '资产',
statImpact: '价格影响',
statWindow: '观察窗口',
evidenceLabel: '证据',
footer: '这些案例用于说明信号背后的历史依据,不构成前瞻性收益承诺。',
footerMethodology: '方法论',
footerGlossary: '术语表',
cases: [
{
id: 'strategic-reserve-2025',
date: '2025 年 3 月 2 日',
source: 'Trump Truth Social',
title: '美国“战略加密储备”表态',
summary: 'Trump 在 Truth Social 上确认美国战略加密储备将涵盖 BTC、ETH、SOL、XRP 和 ADA。',
signal: 'LONG',
asset: 'BTC / ETH',
priceImpact: 'BTC +8.2%,ETH +10%+',
impactWindow: '24 小时',
detail:
'这条帖子之所以重要,不只是因为点名了多个资产,而是因为它把“政府政策支持”从模糊口风变成了明确承诺。Trump Alpha 的语义引擎会把这种带有具体资产清单和国家层面措辞的内容识别为高置信度 LONG 事件。',
evidence:
'CNBC 和 Al Jazeera 均报道了该消息后的市场反应。链上和交易所数据也出现了明显的动量资金流入。',
externalUrl:
'https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html',
},
{
id: 'whale-6m-perp',
date: '2025 年 3 月',
source: '链上 / Truth Social',
title: '战略储备帖子前后出现 680 万美元抢跑收益',
summary:
'一只匿名地址在战略储备帖子发布前后快速建立 50 倍 BTC/ETH 杠杆仓位,并在当天平仓,获利约 680 万美元。',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+680 万美元',
impactWindow: '当日',
detail:
'这个案例说明了两件事:第一,Trump 帖子确实能形成足够大的短线价格冲击;第二,在这种事件中,反应速度本身就是边际优势。无论这笔交易是抢跑还是极快的自动化反应,它都证明了“帖子 -> 交易 -> 盈亏”链条是真实存在的。',
evidence:
'Raw Story / NewsBreak 对这笔交易做了记录,TIME 也提到相关监管层面的质疑与讨论。',
externalUrl:
'https://www.newsbreak.com/raw-story-2096750/4286876592634-that-s-the-maga-playbook-crypto-trade-made-moments-before-trump-post-raises-eyebrows',
},
{
id: 'tariff-china-2025',
date: '2025 年 4 月',
source: 'Trump Truth Social',
title: '关税升级帖子引发风险偏好下移',
summary: 'Trump 发文谈及对中国加征大规模关税,这类表态通常被市场解读为短线 risk-off。',
signal: 'SHORT',
asset: 'BTC',
priceImpact: 'BTC -4.1%',
impactWindow: '4 小时',
detail:
'并不是每条 Trump 帖子都偏利多。带有贸易摩擦、关税升级、监管收紧或避险语义的内容,往往会压缩风险资产偏好。这个案例支持了信号系统中 SHORT 方向存在的必要性。',
evidence:
'CoinDesk 曾整理过多次 Trump 发言影响 BTC 的案例,这次关税语境下的风险偏好回落与其历史模式一致。',
externalUrl:
'https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week',
},
{
id: 'kol-divergence-example',
date: '示意型复合案例',
source: 'KOL 文章 + 链上地址',
title: '公开看多 ETH,但钱包在减仓',
summary:
'某位被追踪的 KOL 发布看多 ETH 的长文,随后 5 天内其钱包减少约 18 万美元 ETH 仓位,构成典型言行偏离。',
signal: 'DIVERGENCE',
asset: 'ETH',
priceImpact: 'N/A',
impactWindow: '±7 天',
detail:
'这个案例不是在讲单条帖子多会拉盘,而是在讲“公开叙事”和“真实仓位”可能不是一回事。对于研究型用户,这类偏离通常比单纯看多或看空更有信息增量。',
evidence:
'言行偏离的判定逻辑来自平台内部交叉信号系统,方法在 Methodology 页面有完整说明。',
},
{
id: 'btc-bottom-nov-2022',
date: '2022 年 11 月',
source: '历史 BTC 周期底部参考',
title: 'FTX 崩盘后的 1.55 万美元区域',
summary:
'FTX 事件后 BTC 跌入约 1.55 万美元区域,随后在 18 个月内回升至 6.9 万美元附近。这是 BTC 周期底部系统试图捕捉的环境类型。',
signal: 'LONG',
asset: 'BTC',
priceImpact: '+345%',
impactWindow: '18 个月',
detail:
'当前线上 BTC 系统并不是去回放旧版链上触发器,而是用 AHR999、200 周均线和 Pi Cycle Bottom 去逼近同一种市场结构:深度杀估值、长期趋势支撑和长期情绪出清同时出现。',
evidence: '公开历史行情和长期图表都把 FTX 洗盘区间视为该轮熊市的底部核心区域。',
},
],
}
}
return {
title: 'Case Studies — Documented Trump Posts That Moved Crypto',
description:
'Documented cases where Trump Truth Social posts, KOL positioning, or macro-bottom conditions moved Bitcoin and crypto markets. Includes price impact, timing, and evidence.',
keywords: [
'Trump crypto market impact',
'Trump Truth Social Bitcoin',
'crypto market moving events',
'Trump BTC trade case study',
'KOL divergence example',
'Bitcoin macro bottom case study',
],
ogTitle: 'Case Studies | Trump Alpha',
ogDescription:
'Historical cases that show why the Trump, BTC, and KOL engines exist: posts, wallet behavior, price reaction, and evidence.',
heroTitle: 'Case Studies',
heroSubtitle: 'Real events, real price moves — why these signals are worth tracking.',
intro: 'Three documented cases where the signal worked. Not forecasts — history.',
statAsset: 'Asset',
statImpact: 'Move',
statWindow: 'Window',
evidenceLabel: 'Source',
footer:
"Past events don't guarantee future results. These cases show why the signal categories exist.",
footerMethodology: 'Methodology',
footerGlossary: 'Glossary',
cases: [
{
id: 'strategic-reserve-2025',
date: 'March 2, 2025',
source: 'Trump Truth Social',
title: 'Trump announces U.S. Strategic Crypto Reserve — BTC +8%, ETH +10%',
summary:
'Trump posted confirmation of a U.S. Strategic Crypto Reserve including BTC, ETH, SOL, XRP, and ADA. Markets moved within minutes.',
signal: 'LONG',
asset: 'BTC / ETH',
priceImpact: 'BTC +8.2%, ETH +10%+',
impactWindow: '24 hours',
detail:
'The post named specific assets and tied them to a government-level commitment — the clearest possible bullish signal. This is exactly what the Trump signal engine is built to catch: a post that goes from vague political tone to explicit crypto policy in one statement.',
evidence:
'CNBC and Al Jazeera documented the market response. Exchange data showed immediate momentum-driven inflows.',
externalUrl:
'https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html',
},
{
id: 'tariff-china-2025',
date: 'April 2025',
source: 'Trump Truth Social',
title: 'Tariff escalation post — BTC -4% in 4 hours',
summary:
'A tariff-escalation post compressed risk appetite fast. BTC dropped 4.1% in 4 hours — no crypto-specific news, just macro sentiment repricing.',
signal: 'SHORT',
asset: 'BTC',
priceImpact: 'BTC -4.1%',
impactWindow: '4 hours',
detail:
'Not every Trump post is bullish for crypto. Trade conflict, tariffs, and macro uncertainty posts can hit crypto just as hard as equity markets. This is why the signal engine has a real SHORT branch — and why noise filtering matters.',
evidence:
'CoinDesk has documented multiple Trump statements that moved BTC in both directions.',
externalUrl:
'https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week',
},
{
id: 'kol-divergence-example',
date: 'Composite example',
source: 'KOL post + tracked wallet',
title: 'KOL bullish on ETH in public, selling ETH on-chain',
summary:
'A tracked KOL published a bullish ETH thesis. Within 5 days, their wallet reduced ~$180k of ETH exposure. Public pitch said buy — the wallet said sell.',
signal: 'DIVERGENCE',
asset: 'ETH',
priceImpact: 'N/A',
impactWindow: '±7 days',
detail:
"Words are free. Wallet moves cost money. When a KOL's on-chain behavior contradicts their public call, the wallet is usually telling the truth. This is why the KOL module tracks both — and why divergence is the highest-conviction category.",
evidence: 'Divergence detection logic is documented on the Methodology page.',
},
],
}
}
async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const locale = 'en'
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/${locale}/case-studies`,
languages: {
en: `${siteUrl}/en/case-studies`,
'x-default': `${siteUrl}/en/case-studies`,
},
},
}
}
const SIGNAL_COLOR: Record<string, string> = {
LONG: '#22c55e',
SHORT: '#ef4444',
DIVERGENCE: '#f5a524',
}
// Map a free-text case date ("March 2, 2025", "April 2025", "Illustrative
// composite") to an ISO date where possible. Returns undefined for vague /
// non-date labels so we omit datePublished rather than emit garbage.
function caseIsoDate(raw: string): string | undefined {
const d = new Date(raw)
return isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10)
}
export default function CaseStudiesPage() {
const locale = 'en'
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
// ── GEO: each case as a schema.org/Article in an ItemList ─────────────────
// These documented event walkthroughs are exactly what AI answer engines
// (Perplexity, Gemini, ChatGPT) cite for "did Trump posts move crypto"
// queries. Structured data makes them machine-citeable. Evidence URLs become
// citation links; Endorphin is the author/publisher.
const pageUrl = `${siteUrl}/${locale}/case-studies`
const caseJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
'@id': `${pageUrl}#cases`,
name: copy.title,
description: copy.description,
itemListElement: copy.cases.map((c, i) => {
const iso = caseIsoDate(c.date)
const article: Record<string, unknown> = {
'@type': 'Article',
'@id': `${pageUrl}#${c.id}`,
headline: c.title,
description: c.summary,
articleBody: c.detail,
about: c.asset,
author: { '@type': 'Organization', name: 'Endorphin', url: siteUrl },
publisher: { '@id': `${siteUrl}/#org` },
isPartOf: { '@id': `${siteUrl}/#website` },
}
if (iso) article.datePublished = iso
if (c.externalUrl) {
article.citation = c.externalUrl
article.isBasedOn = c.externalUrl
}
return { '@type': 'ListItem', position: i + 1, item: article }
}),
}
return (
<div className="page" style={{ maxWidth: 780 }}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(caseJsonLd) }}
/>
<Breadcrumbs items={[{ name: copy.heroTitle, path: `/${locale}/case-studies` }]} />
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<p
style={{
fontSize: 13,
color: 'var(--ink-3)',
lineHeight: 1.7,
marginBottom: 24,
fontStyle: 'italic',
}}
>
{copy.intro}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{copy.cases.map((c) => (
<article
key={c.id}
className="card"
style={{ padding: '24px 28px' }}
itemScope
itemType="https://schema.org/Article"
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
flexWrap: 'wrap',
marginBottom: 12,
}}
>
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
fontFamily: 'monospace',
padding: '4px 10px',
borderRadius: 4,
flexShrink: 0,
background: `color-mix(in srgb, ${SIGNAL_COLOR[c.signal]} 14%, transparent)`,
color: SIGNAL_COLOR[c.signal],
border: `1px solid color-mix(in srgb, ${SIGNAL_COLOR[c.signal]} 30%, transparent)`,
}}
>
{c.signal}
</span>
<span
style={{
fontSize: 12,
color: 'var(--ink-4)',
fontFamily: 'monospace',
flexShrink: 0,
}}
>
{c.date}
</span>
<span
style={{
fontSize: 11,
color: 'var(--ink-4)',
padding: '4px 8px',
background: 'var(--bg-sunk)',
borderRadius: 4,
flexShrink: 0,
}}
>
{c.source}
</span>
</div>
<h2
itemProp="headline"
style={{
fontSize: 17,
fontWeight: 700,
color: 'var(--ink)',
margin: '0 0 8px',
lineHeight: 1.3,
}}
>
{c.title}
</h2>
<p style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.6, margin: '0 0 16px' }}>
{c.summary}
</p>
<div
style={{
display: 'flex',
gap: 0,
marginBottom: 16,
flexWrap: 'wrap',
border: '1px solid var(--line)',
borderRadius: 8,
overflow: 'hidden',
}}
>
{[
{ k: copy.statAsset, v: c.asset },
{ k: copy.statImpact, v: c.priceImpact },
{ k: copy.statWindow, v: c.impactWindow },
].map(({ k, v }, i) => (
<div
key={k}
style={{
flex: '1 1 120px',
padding: '10px 16px',
borderRight: i < 2 ? '1px solid var(--line)' : 'none',
background: 'var(--bg-sunk)',
}}
>
<div
style={{
fontSize: 10,
color: 'var(--ink-4)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontFamily: 'monospace',
marginBottom: 4,
}}
>
{k}
</div>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--ink)',
fontFamily: 'monospace',
}}
>
{v}
</div>
</div>
))}
</div>
<p
itemProp="description"
style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.7, margin: '0 0 12px' }}
>
{c.detail}
</p>
<p
style={{
fontSize: 12,
color: 'var(--ink-3)',
lineHeight: 1.65,
margin: 0,
paddingLeft: 12,
borderLeft: '2px solid var(--line-2)',
}}
>
<strong>{copy.evidenceLabel}:</strong> {c.evidence}
{c.externalUrl && (
<>
{' '}
<a
href={c.externalUrl}
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--amber)', textDecoration: 'none' }}
>
{isZh ? '来源 ↗' : 'Source ↗'}
</a>
</>
)}
</p>
</article>
))}
</div>
<div className="card" style={{ padding: '20px 24px', marginTop: 12 }}>
<p style={{ fontSize: 13, color: 'var(--ink-4)', lineHeight: 1.6, margin: 0 }}>
{copy.footer} {isZh ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/methodology`} style={{ color: 'var(--amber)' }}>
{copy.footerMethodology}
</Link>{' '}
·{' '}
<Link href={`/${locale}/glossary`} style={{ color: 'var(--amber)' }}>
{copy.footerGlossary}
</Link>
.
</p>
</div>
</div>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return {
props: { ...{}, seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }) },
}
}
+90
View File
@@ -0,0 +1,90 @@
import { Input } from '@trumpsignal/ui/components/input'
import { Button } from '@trumpsignal/ui/components/button'
import type { Metadata } from 'next'
const metadata: Metadata = {
title: 'Contact Us',
description:
'Get in touch with Trump Alpha for questions, feedback, or support. We respond within 24 hours.',
alternates: {
canonical: `${process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'}/en/contact`,
},
robots: { index: false, follow: true }, // no SEO value in indexing a contact form
}
export default function ContactPage() {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page" style={{ maxWidth: 560 }}>
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '联系我们' : 'Contact Us'}</h1>
<p className="page-sub">
{isZh
? '问题、反馈或支持请求都可以从这里发来。'
: 'Questions, feedback, or support requests.'}
</p>
</div>
</div>
<div className="card" style={{ padding: 32 }}>
<form action="mailto:support@bitnews.day" method="get" encType="text/plain">
<div className="field" style={{ marginBottom: 16 }}>
<label htmlFor="subject">{isZh ? '主题' : 'Subject'}</label>
<Input
id="subject"
name="subject"
type="text"
placeholder={isZh ? '例如:机器人没有执行交易' : 'e.g. Bot not executing trades'}
/>
</div>
<div className="field" style={{ marginBottom: 24 }}>
<label htmlFor="body">{isZh ? '内容' : 'Message'}</label>
<textarea
id="body"
name="body"
rows={6}
placeholder={
isZh ? '尽量详细描述你的问题或需求…' : 'Describe your issue or question in detail…'
}
style={{ width: '100%', resize: 'vertical' }}
/>
</div>
<Button type="submit" className="btn amber lg">
{isZh ? '用邮件客户端打开 →' : 'Open in mail client →'}
</Button>
</form>
<div
style={{
marginTop: 28,
paddingTop: 20,
borderTop: '1px solid var(--line)',
fontSize: 13,
color: 'var(--ink-3)',
lineHeight: 1.6,
}}
>
<div style={{ marginBottom: 6 }}>
{isZh ? '也可以直接发邮件给我们:' : 'You can also reach us directly:'}
</div>
<div>
<a href="mailto:support@bitnews.day" style={{ color: 'var(--amber)' }}>
support@bitnews.day
</a>
</div>
<div style={{ marginTop: 8 }}>
{isZh ? '通常会在 24 小时内回复。' : 'Response time: typically within 24 hours.'}
</div>
</div>
</div>
</div>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: metadata } }
}
+510
View File
@@ -0,0 +1,510 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
type GlossaryTerm = {
term: string
abbr?: string
category: string
definition: string
extra?: string
}
type GlossaryCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
terms: GlossaryTerm[]
footerLead: string
footerContact: string
footerMethodology: string
footerCaseStudies: string
}
function getCopy(locale: string): GlossaryCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '加密信号术语表 - Trump Alpha',
description:
'系统化定义 Trump Alpha 使用的核心术语,包括 AHR999、Pi Cycle Bottom、200 周均线、KOL、言行偏离、资金费率、Conviction Score、逐仓等。',
keywords: [
'AHR999 定义',
'Pi Cycle Bottom 是什么',
'200周均线',
'KOL 是什么',
'言行偏离',
'资金费率',
'逐仓',
'加密信号术语表',
],
ogTitle: 'Crypto Signals Glossary | Trump Alpha',
ogDescription: 'Trump Alpha 核心术语的清晰定义,适合搜索摘录、AI 引用和快速查阅。',
heroTitle: '术语表',
heroSubtitle: '把页面里所有关键实体和专业词说清楚',
intro:
'这份术语表服务两个目标:第一,帮助用户快速理解平台内的指标和信号;第二,让搜索引擎和 LLM 能稳定识别这些实体之间的关系。每个词条都尽量给出定义、上下文和在 Trump Alpha 里的具体用法。',
terms: [
{
term: 'AHR999',
category: '宏观底部指标',
definition:
'AHR999 是中文加密圈常用的比特币估值指标,通常结合长期趋势和成本锚来判断 BTC 是否进入深度价值区。在 Trump Alpha 里,AHR999 低于 0.45 被视为一个经典底部信号。',
extra: '它是当前 BTC 2-of-3 周期底部扫描器中的三大输入之一。',
},
{
term: 'Pi Cycle Bottom',
category: '宏观底部指标',
definition:
'Pi Cycle Bottom 是一种基于长周期均线关系的比特币底部判定方法。Trump Alpha 使用的条件是 150 日 EMA 低于 471 日 SMA 乘以 0.745。',
extra: '它也是 BTC 周期底部扫描器的三项核心输入之一。',
},
{
term: '200-week Moving Average',
category: '宏观底部指标',
definition:
'200 周均线是比特币最常见的超长周期趋势锚之一。历史上,多轮熊市低点都出现在这个区域附近。Trump Alpha 将价格接近或跌破该均线视为底部共振条件的一部分。',
},
{
term: 'KOL',
abbr: 'Key Opinion Leader',
category: '平台术语',
definition:
'在加密语境里,KOL 指的是能持续影响市场叙事或零售情绪的分析师、基金经理、内容创作者或节目主持人。Trump Alpha 跟踪的是带有长期内容产出的 KOL,而不是只发碎片推文的账号。',
},
{
term: 'Talks-vs-Trades Divergence',
category: '平台术语',
definition:
'言行偏离指 KOL 公开表达的方向与链上钱包实际操作方向相反。例如公开看多 ETH,但钱包在同一周内减仓 ETH。Trump Alpha 把链上动作视作更高优先级的真实信号。',
extra: '这是平台里信息密度最高的信号之一,因为它把公开叙事和真实仓位拆开来看。',
},
{
term: 'Conviction Score',
category: '平台术语',
definition:
'Conviction Score 是 AI 给每条 KOL 观点分配的强度分,范围通常在 0.0 到 1.0。分数越高,说明作者表达越明确、重复度越高,且更可能带有仓位、时间或执行语气。',
},
{
term: 'Funding Rate',
category: '衍生品术语',
definition:
'资金费率是永续合约用来锚定现货价格的周期性支付机制。多头付空头代表市场做多拥挤,空头付多头则代表市场做空拥挤。极端 funding 往往出现在清算前夜。',
},
{
term: 'Isolated Margin',
category: '交易术语',
definition:
'逐仓模式意味着每笔仓位只使用自己的保证金,不会和账户内其他持仓共用风险池。即使某笔交易被强平,也不会拖累其他持仓。',
extra: 'Trump Alpha 的自动执行层默认按逐仓方式工作。',
},
{
term: 'TP / SL',
abbr: 'Take-Profit / Stop-Loss',
category: '交易术语',
definition:
'TP 和 SL 分别表示止盈和止损条件单。Trump Alpha 的自动交易在开仓时就会挂好,而不是事后补挂。',
},
{
term: 'UTXO',
abbr: 'Unspent Transaction Output',
category: '比特币术语',
definition:
'UTXO 是比特币网络中的基础记账单元。虽然 Trump Alpha 当前线上 BTC 信号不再直接使用 UTXO 花费行为,但理解 UTXO 仍有助于读懂很多经典链上研究。',
},
{
term: 'Perpetual Future',
abbr: 'Perp',
category: '衍生品术语',
definition:
'Perp 是没有到期日的永续合约。它依靠资金费率和标记价格机制尽量贴近现货。Hyperliquid 就是提供 perp 交易的去中心化交易平台之一。',
},
{
term: 'Substack Signal',
category: '平台术语',
definition:
'Substack Signal 指从 KOL 的长文订阅、博客或深度文章中提取出的资产观点。相比短推文,这类内容通常更完整,因此更适合做高质量的观点抽取。',
},
{
term: 'Capitulation',
category: '市场术语',
definition:
'Capitulation 指持有者在持续亏损后集中放弃、被迫卖出的一段市场阶段。它往往出现在熊市尾声,也是 BTC 底部扫描器最希望捕捉的环境之一。',
},
{
term: 'EMA',
abbr: 'Exponential Moving Average',
category: '技术术语',
definition:
'EMA 是指数移动平均线,对最近价格赋予更高权重,因此比简单均线更敏感。Trump Alpha 在 Pi Cycle Bottom 条件中使用 150 日 EMA。',
},
],
footerLead: '缺少某个词条?',
footerContact: '联系我们',
footerMethodology: '方法论',
footerCaseStudies: '案例研究',
}
}
return {
title: 'Crypto Signals Glossary — AHR999, Pi Cycle Bottom, KOL Divergence',
description:
'Definitions of every core term used in Trump Alpha, including AHR999, Pi Cycle Bottom, 200-week moving average, KOL, talks-vs-trades divergence, funding rate, conviction score, and isolated margin.',
keywords: [
'AHR999 definition',
'Pi Cycle Bottom explained',
'200-week moving average Bitcoin',
'what is KOL crypto',
'talks vs trades definition',
'funding rate crypto',
'isolated margin perpetual',
'crypto signal glossary',
],
ogTitle: 'Crypto Signals Glossary | Trump Alpha',
ogDescription:
"Clear definitions of Trump Alpha's core terms, optimized for search, AI retrieval, and fast reference.",
heroTitle: 'Glossary',
heroSubtitle: 'Every term on the platform, defined precisely.',
intro: "Quick definitions for the terms you'll encounter on this platform.",
terms: [
{
term: 'AHR999',
category: 'Macro-bottom signal',
definition:
'A Bitcoin valuation score. Below 0.45 = BTC is historically cheap and in a potential bottom zone. Above 1.2 = overvalued, bottom thesis is off. One of three conditions in the BTC bottom scanner.',
},
{
term: 'Pi Cycle Bottom',
category: 'Macro-bottom signal',
definition:
'A long-term moving-average signal that has historically aligned with Bitcoin cycle lows. Fires when the 150-day EMA crosses below the 471-day SMA × 0.745. Binary — either confirmed or not.',
},
{
term: '200-week Moving Average',
category: 'Macro-bottom signal',
definition:
"BTC's most-watched long-term support level. Every major bear-market low in history formed at or near it. The scanner counts price ≤ 200WMA × 1.05 as a bottom vote.",
},
{
term: 'KOL',
abbr: 'Key Opinion Leader',
category: 'Platform term',
definition:
'An analyst, fund manager, podcast host, or creator whose calls move crypto narrative. The platform tracks 25 KOLs — Arthur Hayes, Delphi, Bankless, and others — cross-checking what they say publicly against what their wallets actually do.',
},
{
term: 'Talks-vs-Trades Divergence',
category: 'Platform term',
definition:
"When a KOL's public call and their wallet move in opposite directions on the same asset. Example: publicly bullish ETH while quietly reducing ETH on-chain. The wallet is treated as the real signal — it's harder to fake than words.",
extra: 'Highest-conviction signal category on the platform.',
},
{
term: 'Aligned',
category: 'Platform term',
definition:
"A KOL's public call and their tracked wallet activity agree — e.g., bullish on BTC and the wallet is adding BTC. Reinforces the signal.",
},
{
term: 'Mismatch',
category: 'Platform term',
definition:
"A KOL's public call contradicts their wallet move. Bullish in public, selling on-chain = mismatch. This is the divergence signal you actually want to act on.",
},
{
term: 'Paper mode',
category: 'Platform term',
definition:
'Simulated trading — the bot runs through all its logic and tracks positions, but no real orders are sent to Hyperliquid. Use it to see how the bot would have performed before risking real money.',
},
{
term: '/adopt',
category: 'Platform term',
definition:
'A Telegram bot command. When a Macro Vibes signal fires, you open the position yourself on Hyperliquid, then send /adopt in the bot. The bot then takes over managing the exit — stop ladder, partial de-risk, pyramiding — without you having to watch it.',
},
{
term: 'AI Confidence',
category: 'Platform term',
definition:
'A percentage score (0–100%) the AI assigns to each Trump post signal. Higher = the post language is clearer and more explicitly directional. The default threshold is 70% — signals below this are not traded.',
},
{
term: 'Funding Rate',
category: 'Derivatives term',
definition:
'The periodic payment between long and short traders on a perp exchange, designed to keep the contract price close to spot. Extreme positive funding = crowded longs. Extreme negative = crowded shorts. Extreme readings often precede sharp reversals when the crowded side gets squeezed.',
},
{
term: 'Isolated Margin',
category: 'Trading term',
definition:
"Each trade uses only its own dedicated margin — if it gets liquidated, it doesn't touch your other positions. Trump Alpha always uses isolated margin so one bad trade can't wipe the account.",
},
{
term: 'TP / SL',
abbr: 'Take-Profit / Stop-Loss',
category: 'Trading term',
definition:
'Automatic exit orders placed at entry. TP closes the trade in profit at your target. SL closes it to cut losses. Trump Alpha attaches both the moment a trade opens.',
},
{
term: 'Perpetual Future',
abbr: 'Perp',
category: 'Derivatives term',
definition:
'A futures contract with no expiry. You stay in the trade until you close it or get liquidated. Trump Alpha executes on Hyperliquid perps.',
},
{
term: 'Capitulation',
category: 'Market term',
definition:
'The phase near a bear market bottom where panicked holders give up and sell en masse. Creates the washout conditions that the BTC macro-bottom scanner is designed to detect.',
},
],
footerLead: 'Missing a term?',
footerContact: 'Contact us',
footerMethodology: 'Methodology',
footerCaseStudies: 'Case Studies',
}
}
async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const locale = 'en'
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/en/glossary`,
languages: {
en: `${siteUrl}/en/glossary`,
'x-default': `${siteUrl}/en/glossary`,
},
},
}
}
// Glossary JSON-LD: DefinedTermSet + individual DefinedTerm entries.
// LLMs and Google's Knowledge Graph use this to understand what each term
// means *in context of this product*, which improves citation accuracy when
// users ask "what is [term]" in ChatGPT/Perplexity.
function buildGlossaryJsonLd(terms: { term: string; definition: string; extra?: string }[]) {
return {
'@context': 'https://schema.org',
'@type': 'DefinedTermSet',
'@id': `${siteUrl}/en/glossary#termset`,
name: 'Trump Alpha Crypto Glossary',
url: `${siteUrl}/en/glossary`,
description:
'Definitions of key terms, indicators, and signal concepts used in the Trump Alpha crypto intelligence platform.',
inLanguage: 'en',
hasDefinedTerm: terms.map((t) => ({
'@type': 'DefinedTerm',
name: t.term,
description: t.extra ? `${t.definition} ${t.extra}` : t.definition,
url: `${siteUrl}/en/glossary#${t.term.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
inDefinedTermSet: `${siteUrl}/en/glossary#termset`,
})),
}
}
const CATEGORY_COLOR: Record<string, string> = {
'Macro-bottom metric': '#a78bfa',
宏观底部指标: '#a78bfa',
'Platform term': '#f5a524',
平台术语: '#f5a524',
'Trading term': '#22c55e',
交易术语: '#22c55e',
'Derivatives term': '#60a5fa',
衍生品术语: '#60a5fa',
'Bitcoin term': '#fb923c',
比特币术语: '#fb923c',
'Market term': '#f87171',
市场术语: '#f87171',
'Technical term': '#94a3b8',
技术术语: '#94a3b8',
}
export default function GlossaryPage() {
const locale = 'en'
const copy = getCopy(locale)
const categories = Array.from(new Set(copy.terms.map((t) => t.category)))
const enTerms = locale === 'en' ? copy.terms : getCopy('en').terms
const glossaryJsonLd = buildGlossaryJsonLd(enTerms)
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(glossaryJsonLd) }}
/>
<Breadcrumbs items={[{ name: copy.heroTitle, path: `/${locale}/glossary` }]} />
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<p
style={{
fontSize: 13,
color: 'var(--ink-3)',
lineHeight: 1.7,
marginBottom: 20,
fontStyle: 'italic',
}}
>
{copy.intro}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 24 }}>
{categories.map((cat) => (
<span
key={cat}
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
fontFamily: 'monospace',
padding: '4px 10px',
borderRadius: 999,
background: `color-mix(in srgb, ${CATEGORY_COLOR[cat] ?? '#888'} 12%, transparent)`,
color: CATEGORY_COLOR[cat] ?? '#888',
border: `1px solid color-mix(in srgb, ${CATEGORY_COLOR[cat] ?? '#888'} 25%, transparent)`,
}}
>
{cat}
</span>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{copy.terms.map((term) => (
<div
key={term.term}
id={term.term.toLowerCase().replace(/[^a-z0-9]+/g, '-')}
className="card"
style={{ padding: '20px 24px' }}
itemScope
itemType="https://schema.org/DefinedTerm"
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
<h2
itemProp="name"
style={{
fontSize: 16,
fontWeight: 700,
color: 'var(--ink)',
margin: 0,
flex: '1 1 auto',
}}
>
{term.term}
</h2>
<span
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
fontFamily: 'monospace',
padding: '3px 8px',
borderRadius: 4,
flexShrink: 0,
background: `color-mix(in srgb, ${CATEGORY_COLOR[term.category] ?? '#888'} 12%, transparent)`,
color: CATEGORY_COLOR[term.category] ?? '#888',
}}
>
{term.category}
</span>
</div>
{term.abbr && (
<p
style={{
fontSize: 12,
color: 'var(--ink-4)',
margin: '4px 0 0',
fontStyle: 'italic',
}}
>
{term.abbr}
</p>
)}
<p
itemProp="description"
style={{ fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.7, margin: '10px 0 0' }}
>
{term.definition}
</p>
{term.extra && (
<p
style={{
fontSize: 13,
color: 'var(--ink-3)',
lineHeight: 1.65,
margin: '8px 0 0',
paddingLeft: 12,
borderLeft: `2px solid ${CATEGORY_COLOR[term.category] ?? 'var(--amber)'}`,
}}
>
{term.extra}
</p>
)}
</div>
))}
</div>
<div className="card" style={{ padding: '20px 24px', marginTop: 12 }}>
<p style={{ fontSize: 13, color: 'var(--ink-4)', lineHeight: 1.6, margin: 0 }}>
{copy.footerLead}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>
{copy.footerContact}
</Link>
. {false ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/methodology`} style={{ color: 'var(--amber)' }}>
{copy.footerMethodology}
</Link>{' '}
·{' '}
<Link href={`/${locale}/case-studies`} style={{ color: 'var(--amber)' }}>
{copy.footerCaseStudies}
</Link>
.
</p>
</div>
</div>
</>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return {
props: { ...{}, seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }) },
}
}
+66
View File
@@ -0,0 +1,66 @@
import { getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import DashboardClient from '@/screens/DashboardClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface OverviewPageProps {
params: Promise<{ locale: string }>
}
async function generateMetadata({ params }: OverviewPageProps): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? 'Trump Alpha — 实时加密信号研究台' : 'Trump Alpha — Live Crypto Signal Desk'
const description = isZh
? 'Endorphin 研究台追踪四个先于市场共识的信号:Trump 帖子、BTC 宏观底部、资金费率极值,以及 KOL 言行偏离。每个信号公开且带时间戳。'
: 'Endorphin tracks six signals that move crypto before the crowd: Trump posts, BTC macro bottoms, funding-rate extremes, KOL long-form calls, talks-vs-trades divergence, and the Breakout Monitor. Public and timestamped.'
const path = `${siteUrl}/${locale}`
return {
title,
description,
alternates: {
canonical: path,
languages: {
en: `${siteUrl}/en`,
'x-default': `${siteUrl}/en`,
},
},
openGraph: {
title,
description,
url: path,
locale: isZh ? 'zh_CN' : 'en_US',
alternateLocale: isZh ? ['en_US'] : ['zh_CN'],
},
twitter: {
title,
description,
},
}
}
export default function OverviewPage({ posts }: Awaited<ReturnType<typeof loadData>>) {
return <DashboardClient initialPosts={posts} />
}
async function loadData() {
// The overview only renders a small curated slice on first paint; pulling
// 500 posts here bloats the server payload without improving the initial UI.
const posts = await getPosts(80, 1).catch(() => [])
return { posts }
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
context.res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60')
return {
props: {
...(await loadData()),
seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }),
},
}
}
+128
View File
@@ -0,0 +1,128 @@
import { getKolChanges, getKolDigest, getKolDivergence, getKolPosts } from '@/lib/api'
import type { Metadata } from 'next'
import KolPageClient from '@/screens/kol/KolPageClient'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface KolPageProps {
params: Promise<{ locale: string }>
}
async function generateMetadata({ params }: KolPageProps): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh ? 'KOL 信号与言行偏离追踪' : 'KOL Signals & Talks-vs-Trades Divergence',
description: isZh
? '从 25 个加密 KOL 信息源中提取可执行观点,覆盖 Arthur Hayes、Delphi Digital、Bankless、Empire、Unchained 等,并追踪“说法”和链上仓位是否一致。'
: 'AI-extracted crypto signals from 25 KOL feeds: Arthur Hayes, Delphi Digital, Bankless, Empire, Unchained and more. Includes talks-vs-trades divergence — when KOL wallets contradict their public posts.',
keywords: isZh
? [
'KOL 加密信号',
'Arthur Hayes 信号',
'Delphi Digital 分析',
'KOL 钱包追踪',
'言行不一致',
'链上 KOL 跟踪',
'Substack 加密信号',
'播客加密信号',
]
: [
'KOL crypto signals',
'Arthur Hayes signals',
'Delphi Digital analysis',
'crypto KOL tracker',
'talks vs trades',
'smart money divergence',
'on-chain KOL tracking',
'Substack crypto signals',
'crypto podcast signals',
'KOL wallet tracking',
],
openGraph: {
title: isZh
? 'KOL 信号与言行偏离 | Trump Alpha'
: 'KOL Signals & Talks-vs-Trades | Trump Alpha',
description: isZh
? '每天分析 KOL 长文、播客和公开观点,再与链上仓位交叉验证,找出真正有信息增量的观点与偏离。'
: '25 KOL feeds (Hayes, Bankless, Empire…) AI-scored daily. Plus talks-vs-trades: when their wallets contradict their words.',
},
alternates: {
canonical: `${siteUrl}/en/kol`,
languages: {
en: `${siteUrl}/en/kol`,
'x-default': `${siteUrl}/en/kol`,
},
},
}
}
// GEO: the KOL talks-vs-trades feed is a dataset cross-referencing 25 crypto
// KOLs' public statements against their on-chain wallet behaviour.
const kolDataset = {
'@context': 'https://schema.org',
'@type': 'Dataset',
'@id': `${siteUrl}/en/kol#dataset`,
name: 'Crypto KOL talks-vs-trades divergence dataset',
description:
"Daily cross-reference of 25 crypto KOLs' public statements (Substack / podcast) against their on-chain Ethereum wallet activity, flagging divergence when public stance and real positioning disagree within a ±7-day window.",
url: `${siteUrl}/en/kol`,
keywords: ['crypto KOL', 'on-chain', 'divergence', 'wallet tracking', 'sentiment'],
isAccessibleForFree: true,
creator: { '@type': 'Organization', name: 'Endorphin', url: siteUrl },
publisher: { '@id': `${siteUrl}/#org` },
license: `${siteUrl}/en/terms`,
measurementTechnique: 'On-chain wallet diff vs NLP stance extraction',
temporalCoverage: '2025-01-01/..',
}
export default function KolPage({
posts,
digest,
changes,
divergence,
}: Awaited<ReturnType<typeof loadData>>) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(kolDataset) }}
/>
<Breadcrumbs items={[{ name: 'KOL talks-vs-trades', path: '/en/kol' }]} />
<KolPageClient
initialPosts={posts?.items ?? null}
initialTotal={posts?.total ?? 0}
initialDigest={digest}
initialChanges={changes?.changes ?? null}
initialDivergence={divergence?.items ?? null}
/>
</>
)
}
async function loadData() {
// days=30 must match the client default (KolPageClient kolDays=30) so the
// SSR payload and first client render agree — otherwise content flashes on
// mount. 7d is frequently empty while 30d has data.
const [posts, digest, changes, divergence] = await Promise.all([
getKolPosts({ limit: 50, page: 1, days: 30 }).catch(() => null),
getKolDigest(30).catch(() => null),
getKolChanges({ days: 30 }).catch(() => null),
getKolDivergence({ days: 30 }).catch(() => null),
])
return { posts, digest, changes, divergence }
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
context.res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60')
return {
props: {
...(await loadData()),
seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }),
},
}
}
+97
View File
@@ -0,0 +1,97 @@
import { getFundingSnapshot, getPosts } from '@/lib/api'
import type { Metadata } from 'next'
import MacroVibesPageClient from '@/screens/macro/MacroVibesPageClient'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface MacroVibesPageProps {
params: Promise<{ locale: string }>
}
async function generateMetadata({ params }: MacroVibesPageProps): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh
? '宏观氛围 — 加密市场宏观读数'
: 'Macro Vibes — read crypto macro before price prints',
description: isZh
? 'Trump Alpha 的宏观氛围面板:AHR999、Fear & Greed、BTC 主导率、ETH/BTC、稳定币供应、ETF 资金流、永续 OI、Altcoin Season Index 八大公开宏观指标合一,外加 BTC 周期底部 + 资金费率反转两条触发器。'
: 'Macro vibes for crypto. Eight public macro signals in one view — AHR999, Fear & Greed, BTC dominance, ETH/BTC, stablecoin supply, ETF flows, perp OI, Altcoin Season Index — plus BTC bottom and funding-rate triggers.',
keywords: isZh
? [
'宏观氛围',
'加密宏观',
'AHR999',
'Fear and Greed',
'BTC 主导率',
'ETH/BTC',
'ETF 资金流',
'稳定币供应',
'资金费率反转',
'Altcoin Season Index',
]
: [
'crypto macro',
'macro vibes',
'AHR999',
'Fear and Greed Index',
'BTC dominance',
'ETH/BTC ratio',
'stablecoin supply',
'Bitcoin ETF flow',
'funding rate reversal',
'Altcoin Season Index',
],
openGraph: {
title: isZh ? '宏观氛围 | Trump Alpha' : 'Macro Vibes | Trump Alpha',
description: isZh
? '加密宏观一屏看完:八大宏观指标 + BTC 周期底部 + 资金费率反转触发器。'
: 'Macro vibes for crypto. Read what is about to happen before price prints it.',
},
alternates: {
canonical: `${siteUrl}/en/macro`,
languages: {
en: `${siteUrl}/en/macro`,
'x-default': `${siteUrl}/en/macro`,
},
},
}
}
export default function MacroVibesPage({
posts,
fundingSnapshot,
}: Awaited<ReturnType<typeof loadData>>) {
return (
<>
<Breadcrumbs items={[{ name: 'Macro Vibes', path: '/en/macro' }]} />
<MacroVibesPageClient initialPosts={posts} initialFundingSnapshot={fundingSnapshot} />
</>
)
}
async function loadData() {
const [posts, fundingSnapshot] = await Promise.all([
// The default tab is the bottom-reversal view. Fetch only the source this
// page actually renders on first paint instead of hydrating with the
// latest global post firehose and filtering it client-side.
getPosts(200, 1, 'btc_bottom_reversal').catch(() => null),
getFundingSnapshot().catch(() => null),
])
return { posts, fundingSnapshot }
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
context.res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60')
return {
props: {
...(await loadData()),
seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }),
},
}
}
+562
View File
@@ -0,0 +1,562 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
type Locale = 'en' | 'zh'
type MethodSection = {
tag: string
title: string
answer: string
paragraphs: string[]
formula?: string
bullets?: string[]
limitation: string
}
type MethodologyCopy = {
title: string
description: string
keywords: string[]
ogTitle: string
ogDescription: string
heroTitle: string
heroSubtitle: string
intro: string
sections: MethodSection[]
footer: string
footerLinks: {
contact: string
glossary: string
caseStudies: string
}
}
function getCopy(locale: string): MethodologyCopy {
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
if (isZh) {
return {
title: '信号方法论 - Trump Alpha',
description:
'完整解释 Trump Alpha 六个信号引擎的工作方式,包括 Trump Truth Social 语义打分、BTC 周期底部 2-of-3 共振、KOL 观点抽取、言行偏离、资金费率反转和 Hyperliquid 自动执行风控模型。',
keywords: [
'加密信号方法论',
'AHR999',
'Pi Cycle Bottom',
'200周均线',
'Trump Truth Social 信号',
'KOL 观点抽取',
'言行偏离',
'资金费率反转',
'Hyperliquid 自动交易',
],
ogTitle: 'Signal Methodology | Trump Alpha',
ogDescription: 'Trump Alpha 六个引擎的完整方法论:数据源、触发规则、执行方式和已知限制。',
heroTitle: '信号方法论',
heroSubtitle: '从原始数据到交易动作,逐个解释每个引擎到底怎么工作',
intro:
'Trump Alpha 不是一个单一策略,而是六个相互独立的引擎。每个引擎都有自己的数据源、触发条件和失败模式。这里给出的是当前线上版本的真实逻辑,而不是营销版摘要。',
sections: [
{
tag: 'Signal 1 · 实时事件',
title: 'Trump Truth Social 情绪引擎',
answer:
'这个引擎实时轮询 Donald Trump 的 Truth Social 帖子,并在几秒内判断帖子是否会对加密市场形成短线方向性冲击。',
paragraphs: [
'系统只处理带有文字内容的原帖或转帖。纯图片、纯链接和没有可解释文本的信息会被过滤掉,因为这些内容更容易产生误触发。',
'每条帖子会被送进结构化语义分类流程,输出方向、主要资产和置信度。只有当置信度高于用户门槛,且交易时间窗、日预算、仓位上限都允许时,自动执行层才会放行。',
],
formula: `if classification != NOISE
AND confidence >= user_threshold
AND current_time in active_window
AND daily_spend < daily_cap
=> open isolated-margin trade`,
bullets: [
'方向标签:LONG / SHORT / NOISE',
'默认场景:突发政策表态、监管口径、关税升级、美元或财政扩张相关表述',
'风控层:逐仓、止盈止损、日预算上限、活跃时段、单笔仓位上限',
],
limitation:
'这个模型理解的是“帖子语义”,不是完整宏观环境。它很适合事件驱动,但不保证在风险偏好急转直下的市场里仍有同样的盈亏比。',
},
{
tag: 'Signal 2 · 日频 · BTC',
title: 'BTC 周期底部 2-of-3 共振',
answer:
'当前线上 BTC 系统使用三项经典底部指标做 2-of-3 共振,只在宏观底部区域附近给出低频的 long-only 信号。',
paragraphs: [
'这套逻辑不再依赖付费链上数据,而是改成更稳健、更容易验证的公开价格框架。核心思想没变:抓大级别熊市尾声,而不是做日内择时。',
'扫描器每天只跑一次,因为这不是高频策略。它要解决的不是“今天能不能涨 2%”,而是“是否正在进入一个值得持有数月的反转区间”。',
],
formula: `signal fires if count(
AHR999 < 0.45,
price <= 200-week MA * 1.05,
Pi Cycle Bottom true
) >= 2`,
bullets: [
'2-of-3 触发时,信心分高于单指标方案',
'只做 long-only,不做趋势中的逆势 short',
'退出更偏向保护利润和周期持有,而不是固定止盈',
],
limitation:
'这套方法天生滞后于最低点。它接受“抄不到最底”,换取更少的中继下跌误判。最大的风险是把中期调整误判成结构性底部。',
},
{
tag: 'Signal 3 · 日频 · KOL',
title: 'KOL 长文与播客观点抽取',
answer:
'KOL 引擎每天抓取长文、博客和播客描述,提取出明确的资产观点,并区分是明确买卖、方向性看法,还是只是提及。',
paragraphs: [
'比起碎片化推文,长文和播客摘要更容易包含完整论证、仓位语气和时间窗口,因此对 AI 提取更友好,也更适合作为可回溯的数据资产。',
'输出结构不只包含 ticker,还包含动作类型、置信度和最短支撑引用句。这样前端和后续检索系统都能拿到“观点 + 证据”的组合,而不只是一个标签。',
],
formula: `{
"summary": "<thesis>",
"tickers": [
{
"ticker": "BTC",
"action": "buy | sell | bullish | bearish | mention",
"conviction": 0.0-1.0,
"quote": "<supporting sentence>"
}
]
}`,
bullets: [
'buy / sell 表示作者明确表达了交易动作',
'bullish / bearish 表示方向明确但没有出现实际下单表述',
'mention 仅表示提到标的,不足以视作交易观点',
],
limitation:
'KOL 观点提取的难点在于上下文。历史复盘、赞助口播、反讽和模糊措辞都会降低可信度,因此系统对噪音过滤比较保守。',
},
{
tag: 'Signal 4 · 日频 · 交叉验证',
title: 'Talks-vs-Trades 言行偏离',
answer:
'当 KOL 公开表达的方向,与其链上钱包在同一资产上的实际操作相反时,系统会把链上行为视作更高优先级的真实信号。',
paragraphs: [
'这个模块把“说了什么”和“做了什么”分开建模。公开叙事影响舆论,但仓位变化更接近真实风险暴露,因此两者冲突时,链上动作通常更有信息增量。',
'目前系统会在 KOL 发文后的 ±7 天窗口内查找同资产的仓位变化,并将新增/加仓归为 long action,把减仓/清仓归为 short action。',
],
formula: `LONG_INTENT + SHORT_ACTION => DIVERGENCE (short)
SHORT_INTENT + LONG_ACTION => DIVERGENCE (long)
LONG_INTENT + LONG_ACTION => ALIGNMENT
SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
bullets: [
'公开看多但链上减仓:偏离,方向按 short 解释',
'公开看空但链上加仓:偏离,方向按 long 解释',
'公开观点和链上仓位一致:一致性增强,不一定是反向机会',
],
limitation:
'当前可见性仍受限于已知钱包集合。一个 KOL 可能分散使用多个地址、CEX 或未标记账户,因此偏离信号属于高质量但不保证覆盖完整仓位。',
},
{
tag: 'Signal 5 · 小时级 · 衍生品',
title: '资金费率极值反转',
answer:
'资金费率引擎不是追涨杀跌,而是盯着拥挤度。当多头或空头过度拥挤,且极值开始均值回归时,系统会给出反向信号。',
paragraphs: [
'永续合约的 funding rate 能直观反映杠杆倾斜。如果市场长期是“多头在付钱”,说明做多拥挤;反过来则代表空头拥挤。极端拥挤往往在清算前夜。',
'当前前端展示的是 30 日累计 funding 和实时状态,便于直接判断是否进入危险区,而不是只看单个时间点的 funding 数字。',
],
formula: `30d cumulative funding >= +3% and mean-reverting => SHORT bias
30d cumulative funding <= -3% and mean-reverting => LONG bias`,
bullets: [
'用累计 funding 代替单点读数,更适合识别持续拥挤',
'方向是反向交易 crowd,而不是顺着 crowd',
'适合做拥挤度监控,也适合和其他系统做组合验证',
],
limitation:
'资金费率极值可以持续比预期更久。它更像条件成熟的提醒,而不是保证立刻反转的钟摆,因此执行层必须配合止损和仓位控制。',
},
{
tag: 'Signal 6 · 执行层',
title: 'Hyperliquid 自动交易与安全模型',
answer:
'自动交易层的职责不是“提高收益率”,而是把信号执行标准化,并在风控上强制落实逐仓、止损、预算和时间窗等约束。',
paragraphs: [
'Trump Alpha 通过 trade-only API key 与 Hyperliquid 连接,当前不会接触用户私钥或提现权限。这意味着系统能执行交易,但不能挪走资产。',
'自动执行目前只接入 Trump Truth Social 实时信号。BTC 周期底部、KOL 观点和言行偏离依旧定位为研究与辅助决策层,默认由用户手动处理。',
],
bullets: [
'最低置信度门槛',
'逐仓模式',
'开仓即挂止盈止损',
'日预算上限',
'活跃时间窗',
'单笔仓位上限',
],
limitation:
'执行层再稳,也无法把错误信号变成好交易。它能降低人为偏差,但不能替代策略本身的边际优势。',
},
],
footer: '方法论最后更新于 2026 年 5 月。页面会随着线上逻辑更新而同步修订。',
footerLinks: {
contact: '联系我们',
glossary: '术语表',
caseStudies: '案例研究',
},
}
}
return {
title: 'Signal Methodology — How Each Engine Works',
description:
"Full technical methodology behind Trump Alpha's live signal engines: Trump Truth Social AI scoring, BTC 2-of-3 macro-bottom confluence, KOL feed extraction, talks-vs-trades divergence, funding rate reversals, and the Hyperliquid auto-trader safety model.",
keywords: [
'crypto signal methodology',
'AHR999 explained',
'Pi Cycle Bottom',
'200-week moving average Bitcoin',
'Trump Truth Social AI scoring',
'KOL signal extraction',
'talks vs trades methodology',
'funding rate reversal signal',
'Hyperliquid isolated margin',
],
ogTitle: 'Signal Methodology | Trump Alpha',
ogDescription:
'How each Trump Alpha engine works: data sources, trigger logic, execution rules, and known limitations.',
heroTitle: 'Signal Methodology',
heroSubtitle: 'From raw data to trade decision — how each engine actually works.',
intro:
'Endorphin runs Trump Alpha as a research project, not a marketing funnel. It is not one monolithic strategy — it is a stack of six independent engines, each with its own data source, trigger logic, and failure mode. This page documents the live production logic, not a simplified pitch.',
sections: [
{
tag: 'Signal 1 · Real-time event',
title: 'Trump Truth Social Sentiment Engine',
answer:
'This engine watches Donald Trump’s Truth Social feed in real time and scores whether a new post is likely to create a short-term directional move in crypto.',
paragraphs: [
'Only text-bearing original posts and reposts are scored. Pure media reposts, link dumps, and non-interpretable content are filtered out because they generate too many false positives.',
'Each post goes through a structured semantic classification step that returns direction, primary asset, and confidence. Auto-execution only proceeds when confidence clears the user threshold and all user-level trade constraints still allow a new position.',
],
formula: `if classification != NOISE
AND confidence >= user_threshold
AND current_time in active_window
AND daily_spend < daily_cap
=> open isolated-margin trade`,
bullets: [
'Direction labels: LONG / SHORT / NOISE',
'Typical catalysts: policy comments, tariff escalation, regulation, fiscal expansion, dollar weakness',
'Safety rails: isolated margin, TP/SL, daily budget cap, active hours, per-trade size cap',
],
limitation:
'The model interprets post semantics, not full macro context. It is strong for event-driven reaction, but it does not guarantee the same edge during broad risk-off regimes.',
},
{
tag: 'Signal 2 · Daily · BTC',
title: 'BTC Macro-Bottom 2-of-3 Confluence',
answer:
'The live BTC engine uses three classic bottom markers and only fires a low-frequency long-only signal when at least two of them agree.',
paragraphs: [
'The production system no longer depends on premium on-chain feeds. It now uses a more durable public-price framework that is easier to verify, easier to operate, and less brittle under vendor changes.',
'This scanner runs once a day because it is solving a cycle-level problem, not an intraday one. The question is not whether BTC can bounce today, but whether the market is entering a region that deserves multi-month exposure.',
],
formula: `signal fires if count(
AHR999 < 0.45,
price <= 200-week MA * 1.05,
Pi Cycle Bottom true
) >= 2`,
bullets: [
'2-of-3 agreement carries more weight than any single indicator',
'Long only by design; it does not try to fade uptrends with short signals',
'Exit logic is built around capital protection and cycle capture, not fixed profit targets',
],
limitation:
'This method is intentionally late relative to the exact wick low. It accepts that trade-off to reduce the chance of confusing a mid-cycle correction with a structural bottom.',
},
{
tag: 'Signal 3 · Daily · KOL',
title: 'KOL Long-Form Thesis Extraction',
answer:
'The KOL engine ingests long-form writing, blogs, and podcast descriptions, then extracts explicit asset views and separates true trade calls from soft mentions.',
paragraphs: [
'Long-form sources tend to include fuller reasoning, clearer sizing language, and more precise timing than short social posts. That makes them better raw material for a retrieval-friendly signal archive.',
'The output includes not only the ticker and action, but also a conviction score and the shortest supporting quote. That gives both the UI and downstream retrieval systems access to view plus evidence, not just a label.',
],
formula: `{
"summary": "<thesis>",
"tickers": [
{
"ticker": "BTC",
"action": "buy | sell | bullish | bearish | mention",
"conviction": 0.0-1.0,
"quote": "<supporting sentence>"
}
]
}`,
bullets: [
'buy / sell means the author explicitly states a trade action',
'bullish / bearish means directional intent without an explicit trade statement',
'mention means the asset appears but the text is not strong enough to treat as a call',
],
limitation:
'Context is the hard part. Historical references, sponsor segments, irony, and vague thesis language all reduce extraction quality, so the filters are intentionally conservative.',
},
{
tag: 'Signal 4 · Daily · Cross-check',
title: 'Talks-vs-Trades Divergence',
answer:
'When a KOL’s public stance and wallet behavior point in opposite directions on the same asset, the engine treats the wallet move as the higher-priority truth signal.',
paragraphs: [
'This module models “what was said” separately from “what was done.” Public narrative can move sentiment, but wallet changes are closer to actual risk exposure. When the two conflict, the on-chain action usually contains more information.',
'The current implementation scans a ±7 day window around a KOL post and maps new positions or adds to long action, while reductions and exits map to short action.',
],
formula: `LONG_INTENT + SHORT_ACTION => DIVERGENCE (short)
SHORT_INTENT + LONG_ACTION => DIVERGENCE (long)
LONG_INTENT + LONG_ACTION => ALIGNMENT
SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
bullets: [
'Publicly bullish while reducing the wallet position becomes a short-style divergence',
'Publicly bearish while adding to the wallet position becomes a long-style divergence',
'Alignment boosts confidence, but it is not automatically a contrarian trade',
],
limitation:
'Coverage still depends on known wallets. A KOL can split risk across multiple addresses, exchanges, or unlabeled accounts, so divergence is high quality but not guaranteed full-balance visibility.',
},
{
tag: 'Signal 5 · Hourly · Derivatives',
title: 'Funding Rate Extreme Reversal',
answer:
'The funding engine is not chasing price. It watches crowding and looks for mean reversion after leverage has become one-sided enough to create liquidation risk.',
paragraphs: [
'Perpetual funding rates are a direct lens into leverage tilt. If longs keep paying, the long side is crowded. If shorts keep paying, the short side is crowded. Extreme crowding often precedes violent squeezes in the opposite direction.',
'The live UI emphasizes 30-day cumulative funding and current state, which makes crowding easier to interpret than isolated single-print readings.',
],
formula: `30d cumulative funding >= +3% and mean-reverting => SHORT bias
30d cumulative funding <= -3% and mean-reverting => LONG bias`,
bullets: [
'Cumulative funding is more useful than single-point funding for persistent crowd detection',
'The trade idea is to fade the crowd, not to join it',
'Useful as a standalone crowding monitor or as confirmation for other systems',
],
limitation:
'Funding extremes can persist longer than expected. The signal is a condition alert, not a guarantee of immediate reversal, so position sizing and stop logic still matter.',
},
{
tag: 'Signal 6 · Execution layer',
title: 'Hyperliquid Auto-Trader and Safety Model',
answer:
'The auto-trader exists to standardize execution and enforce risk constraints, not to manufacture edge out of a weak signal.',
paragraphs: [
'Trump Alpha connects to Hyperliquid through a trade-only API key class. The system can submit and manage trades, but it does not have withdrawal rights and does not touch wallet private keys.',
'Today the auto-trader is wired to the Trump Truth Social engine only. BTC macro-bottom, KOL thesis extraction, and divergence remain research and decision-support layers by default.',
],
bullets: [
'Minimum confidence threshold',
'Isolated margin',
'TP/SL attached at entry',
'Daily budget cap',
'Active trading window',
'Per-trade position cap',
],
limitation:
'Execution hygiene cannot turn a bad signal into a good trade. It reduces operational mistakes, but it cannot replace strategy edge.',
},
],
footer:
'Methodology last updated in May 2026. This page is revised whenever live logic changes.',
footerLinks: {
contact: 'Contact',
glossary: 'Glossary',
caseStudies: 'Case Studies',
},
}
}
async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const locale = 'en'
const copy = getCopy(locale)
return {
title: copy.title,
description: copy.description,
keywords: copy.keywords,
openGraph: {
title: copy.ogTitle,
description: copy.ogDescription,
},
alternates: {
canonical: `${siteUrl}/en/methodology`,
languages: {
en: `${siteUrl}/en/methodology`,
'x-default': `${siteUrl}/en/methodology`,
},
},
}
}
// TechArticle JSON-LD: tells Google (and AI crawlers) this is authoritative
// technical documentation, not marketing copy. Improves likelihood of being
// cited by Perplexity/ChatGPT when answering "how does X work" questions.
const methodologyJsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: 'Signal Methodology — How Each Engine Works',
description:
'Full technical documentation of all six Trump Alpha signal engines: Trump sentiment, BTC macro-bottom 2-of-3 confluence, KOL long-form extraction, talks-vs-trades divergence, funding rate reversal, and Hyperliquid auto-trader safety model.',
url: `${siteUrl}/en/methodology`,
datePublished: '2025-01-01',
dateModified: new Date().toISOString().slice(0, 10),
inLanguage: 'en',
author: { '@type': 'Organization', name: 'Trump Alpha', url: siteUrl },
publisher: { '@type': 'Organization', name: 'Trump Alpha', url: siteUrl },
about: [
{ '@type': 'Thing', name: 'Trump Truth Social crypto signals' },
{ '@type': 'Thing', name: 'Bitcoin macro bottom indicators' },
{ '@type': 'Thing', name: 'AHR999 indicator' },
{ '@type': 'Thing', name: 'Pi Cycle Bottom' },
{ '@type': 'Thing', name: 'KOL on-chain divergence' },
{ '@type': 'Thing', name: 'Hyperliquid perpetual trading' },
],
isPartOf: { '@type': 'WebSite', name: 'Trump Alpha', url: siteUrl },
}
const SECTION_STYLE = {
borderTop: '1px solid var(--line)',
paddingTop: 32,
marginTop: 32,
}
const H2 = { fontSize: 20, fontWeight: 700, color: 'var(--ink)', marginBottom: 12, lineHeight: 1.3 }
const P = { fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.75, marginBottom: 12 }
const LI = { fontSize: 14, color: 'var(--ink-2)', lineHeight: 1.75, marginBottom: 6 }
function Tag({ children, color = 'var(--amber)' }: { children: string; color?: string }) {
return (
<span
style={{
display: 'inline-block',
fontSize: 10,
fontWeight: 700,
letterSpacing: '0.07em',
textTransform: 'uppercase',
fontFamily: 'monospace',
padding: '3px 8px',
borderRadius: 4,
background: `color-mix(in srgb, ${color} 12%, transparent)`,
color,
marginBottom: 12,
}}
>
{children}
</span>
)
}
function Formula({ children }: { children: React.ReactNode }) {
return (
<div
style={{
fontFamily: 'monospace',
fontSize: 13,
background: 'var(--bg-sunk)',
border: '1px solid var(--line)',
borderRadius: 8,
padding: '14px 18px',
color: 'var(--ink)',
margin: '12px 0',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
}}
>
{children}
</div>
)
}
export default function MethodologyPage() {
const locale = 'en'
const copy = getCopy(locale)
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(methodologyJsonLd) }}
/>
<Breadcrumbs items={[{ name: copy.heroTitle, path: `/${locale}/methodology` }]} />
<div className="page" style={{ maxWidth: 760 }}>
<div className="page-head">
<div>
<h1 className="page-title">{copy.heroTitle}</h1>
<p className="page-sub">{copy.heroSubtitle}</p>
</div>
</div>
<div className="card" style={{ padding: '28px 32px' }}>
<p style={{ ...P, color: 'var(--ink-3)', fontStyle: 'italic' }}>{copy.intro}</p>
{copy.sections.map((section, index) => (
<section key={section.title} style={SECTION_STYLE}>
<Tag color={index % 2 === 0 ? 'var(--amber)' : 'var(--violet, #a78bfa)'}>
{section.tag}
</Tag>
<h2 style={H2}>{section.title}</h2>
<p style={P}>
<strong>{isZh ? '直接结论:' : 'Direct answer:'}</strong> {section.answer}
</p>
{section.paragraphs.map((paragraph) => (
<p key={paragraph} style={P}>
{paragraph}
</p>
))}
{section.formula && <Formula>{section.formula}</Formula>}
{section.bullets && (
<ul style={{ paddingLeft: 20 }}>
{section.bullets.map((item) => (
<li key={item} style={LI}>
{item}
</li>
))}
</ul>
)}
<p style={{ ...P, color: 'var(--ink-3)' }}>
<strong>{isZh ? '已知限制:' : 'Known limitation:'}</strong> {section.limitation}
</p>
</section>
))}
<div style={{ ...SECTION_STYLE, borderTop: '1px solid var(--line)' }}>
<p style={{ ...P, color: 'var(--ink-4)', fontSize: 12 }}>
{copy.footer}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>
{copy.footerLinks.contact}
</Link>
. {isZh ? '另见:' : 'See also:'}{' '}
<Link href={`/${locale}/glossary`} style={{ color: 'var(--amber)' }}>
{copy.footerLinks.glossary}
</Link>{' '}
·{' '}
<Link href={`/${locale}/case-studies`} style={{ color: 'var(--amber)' }}>
{copy.footerLinks.caseStudies}
</Link>
.
</p>
</div>
</div>
</div>
</>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return {
props: { ...{}, seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }) },
}
}
+7
View File
@@ -0,0 +1,7 @@
export default function Redirect() {
return null
}
export const getServerSideProps: import('next').GetServerSideProps = async ({ params }) =>
params?.locale !== 'en'
? { notFound: true }
: { redirect: { destination: '/en/trump', permanent: true } }
+114
View File
@@ -0,0 +1,114 @@
import type { Metadata } from 'next'
import Link from 'next/link'
const _base = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
const metadata: Metadata = {
title: 'Privacy Policy',
description:
'Trump Alpha privacy policy: what data we collect, how your Hyperliquid API key is stored encrypted, and your rights.',
alternates: { canonical: `${_base}/en/privacy` },
robots: { index: true, follow: true },
}
export default function PrivacyPage() {
const locale = 'en'
const intlLocale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const sections = isZh
? [
{
title: '1. 我们收集什么',
body: '当你连接钱包时,我们会收集你的钱包地址,用于身份识别、订阅状态判断和机器人配置关联。除非你主动通过联系表单提供,我们不会收集姓名、邮箱等传统个人身份信息。',
},
{
title: '2. Hyperliquid API Key',
body: '如果你选择绑定 Hyperliquid API key,该密钥会以加密形式存储。明文不会被写入日志,也不会用于除 Hyperliquid 官方交易接口以外的用途。',
},
{
title: '3. 数据如何使用',
body: '钱包地址、订阅状态和交易配置只会用于执行你授权的机器人逻辑、展示个人交易表现,以及提供平台功能。我们不会出售、出租或用于广告投放。',
},
{
title: '4. 分析与统计',
body: '我们使用极简统计来理解页面访问、错误率和整体使用趋势。这些统计不会绑定你的真实身份,也不会用于跨站广告追踪。',
},
{
title: '5. 数据保留',
body: '只要你的账户仍在使用,相关订阅与配置数据会被保留。你可以联系我们请求删除。交易历史最多保留 24 个月,用于对账、争议处理和产品分析。',
},
{
title: '6. 联系方式',
body: '如果你有与隐私相关的问题,请使用联系页面与我们沟通。',
},
]
: [
{
title: '1. Information We Collect',
body: 'When you connect a wallet, we collect the wallet address required to identify your account, check subscription status, and link bot settings. We do not collect names, email addresses, or similar personal identifiers unless you voluntarily provide them through the contact form.',
},
{
title: '2. Hyperliquid API Keys',
body: 'If you connect a Hyperliquid API key, the key is stored in encrypted form. Plaintext keys are not written to logs and are only used for calls to official Hyperliquid trading endpoints.',
},
{
title: '3. How We Use Data',
body: 'Wallet addresses, subscription status, and trading settings are used only to run the bot logic you authorize, show your personal trading analytics, and operate product features. We do not sell, rent, or repurpose this data for advertising.',
},
{
title: '4. Analytics',
body: 'We use minimal analytics to understand page usage, error rates, and aggregate product patterns. These analytics are not tied to your real-world identity and are not used for cross-site advertising.',
},
{
title: '5. Data Retention',
body: 'Subscription and configuration data is retained while your account remains active. You may request deletion by contacting us. Trade-history records are retained for up to 24 months for reconciliation, dispute handling, and product analysis.',
},
{
title: '6. Contact',
body: 'If you have a privacy-related question, please use the contact page.',
},
]
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '隐私政策' : 'Privacy Policy'}</h1>
<p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div>
</div>
<div
className="card"
style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}
>
{sections.map((section, index) => (
<div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>
{section.title}
</h2>
<p style={{ marginBottom: 0 }}>
{section.title.endsWith('Contact') || section.title.endsWith('联系方式') ? (
<>
{section.body}{' '}
<Link href={`/${locale}/contact`} style={{ color: 'var(--amber)' }}>
{isZh ? '联系页面' : 'contact page'}
</Link>
.
</>
) : (
section.body
)}
</p>
</div>
))}
</div>
</div>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: metadata } }
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from 'next'
import SettingsClient from '@/screens/settings/SettingsClient'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
async function generateMetadata(): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '机器人设置与订阅' : 'Bot Settings & Subscription'
const description = isZh
? '配置订阅状态、Hyperliquid API、风控参数、自动交易模式和提醒渠道。这里决定 Trump Alpha 如何代表你执行。'
: 'Configure subscription state, Hyperliquid API access, risk controls, auto-trade mode, and alert channels. This page defines how Trump Alpha executes for you.'
return {
title,
description,
alternates: {
canonical: `${siteUrl}/en/settings`,
languages: {
en: `${siteUrl}/en/settings`,
},
},
}
}
export default function SettingsPage() {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '设置' : 'Settings'}</h1>
<p className="page-sub">
{isZh
? '订阅、交易所密钥、风险限制和运行时间安排,都会在这里配置。'
: 'Subscription, exchange key, risk limits, and schedule — everything that decides how the bot trades.'}
</p>
</div>
</div>
<SettingsClient />
</div>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: await generateMetadata() } }
}
+117
View File
@@ -0,0 +1,117 @@
import type { Metadata } from 'next'
const _base = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
const metadata: Metadata = {
title: 'Terms of Service',
description:
'Trump Alpha terms of service: non-investment-advice disclaimer, risk disclosure, and permitted use of the platform.',
alternates: { canonical: `${_base}/en/terms` },
robots: { index: true, follow: true },
}
export default function TermsPage() {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const sections = isZh
? [
{
title: '1. 接受条款',
body: '当你连接钱包或使用 Trump Alpha 时,就表示你接受这些服务条款。如果你不同意,请不要继续使用。',
},
{
title: '2. 非投资建议',
body: 'Trump Alpha 是研究与自动化工具,不是财务顾问。所有信号、AI 分析和自动交易能力都只提供信息与执行工具,不构成投资建议。',
},
{
title: '3. 风险披露',
body: '加密货币和杠杆交易都伴随显著风险,损失可能超过你的初始投入。开启自动交易前,你需要自行理解并接受这些风险。',
},
{
title: '4. 允许用途',
body: '你可以将 Trump Alpha 用于个人、非商业目的。未经许可,不得反向工程、批量抓取、转售或重新分发平台上的数据与信号。',
},
{
title: '5. 服务可用性',
body: '我们会尽量保持高可用,但不保证平台永远不中断。计划维护、基础设施故障或第三方 API 异常都可能影响服务。',
},
{
title: '6. 条款更新',
body: '我们可能会在未来更新这些条款。更新后继续使用服务,即视为你接受新版本条款。',
},
{
title: '7. 责任限制',
body: '在法律允许的最大范围内,Trump Alpha 及其运营方不对因使用本服务而产生的间接损失、附带损失或交易损失承担责任。',
},
]
: [
{
title: '1. Acceptance',
body: 'By connecting a wallet or using Trump Alpha, you accept these Terms of Service. If you do not agree, do not use the product.',
},
{
title: '2. Not Financial Advice',
body: 'Trump Alpha is a research and automation tool, not a financial advisor. All signals, AI analysis, and auto-trading capabilities are provided as information and execution tooling, not as investment advice.',
},
{
title: '3. Risk Disclosure',
body: 'Crypto trading and leverage both involve significant risk, including the possibility of losses greater than your initial capital. You are responsible for understanding and accepting those risks before enabling automation.',
},
{
title: '4. Permitted Use',
body: 'You may use Trump Alpha for personal, non-commercial purposes. You may not reverse-engineer, bulk scrape, resell, or redistribute platform data or signals without permission.',
},
{
title: '5. Service Availability',
body: 'We aim for high availability, but we do not guarantee uninterrupted service. Maintenance, infrastructure issues, or third-party API outages may affect the platform.',
},
{
title: '6. Modifications',
body: 'We may update these terms in the future. Continued use of the service after changes means you accept the updated version.',
},
{
title: '7. Limitation of Liability',
body: 'To the maximum extent permitted by law, Trump Alpha and its operators are not liable for indirect damages, incidental damages, or trading losses arising from use of the service.',
},
]
return (
<div className="page" style={{ maxWidth: 720 }}>
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '服务条款' : 'Terms of Service'}</h1>
<p className="page-sub">{isZh ? '最后更新:2026 年 4 月' : 'Last updated: April 2026'}</p>
</div>
</div>
<div
className="card"
style={{ padding: 32, lineHeight: 1.7, fontSize: 14, color: 'var(--ink-2)' }}
>
{sections.map((section, index) => (
<div key={section.title} style={{ marginBottom: index === sections.length - 1 ? 0 : 20 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, color: 'var(--ink)', marginBottom: 8 }}>
{section.title}
</h2>
<p style={{ marginBottom: 0 }}>
{section.title.includes('Not Financial Advice') ||
section.title.includes('非投资建议') ? (
<>
<strong style={{ color: 'var(--ink)' }}>{section.body}</strong>
</>
) : (
section.body
)}
</p>
</div>
))}
</div>
</div>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: metadata } }
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata } from 'next'
import TradesPageClient from '@/screens/trades/TradesPageClient'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
async function generateMetadata(): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const title = isZh ? '交易执行与持仓' : 'Trades & Open Positions'
const description = isZh
? '查看当前持仓、历史成交、按信号来源拆分的盈亏,以及机器人是否已经具备真实执行条件。'
: 'Review open positions, historical executions, source-level P&L, and whether the bot is ready for live trading.'
return {
title,
description,
openGraph: { title, description },
alternates: {
canonical: `${siteUrl}/${locale}/trades`,
languages: {
en: `${siteUrl}/en/trades`,
},
},
}
}
// GEO: the trade history is a dataset — every bot execution with entry/exit,
// P&L, hold time, and the signal that triggered it. Cited for "track record"
// and "does it work" queries.
const tradesDataset = {
'@context': 'https://schema.org',
'@type': 'Dataset',
'@id': `${siteUrl}/en/trades#dataset`,
name: 'Trump Alpha trade execution history',
description:
'Per-wallet record of signal-triggered trades: asset, direction, entry and exit price, realised P&L, hold time, and the triggering signal. Private to each wallet owner — wallet signature required to view.',
url: `${siteUrl}/en/trades`,
keywords: ['crypto trading track record', 'Hyperliquid', 'P&L', 'signal performance'],
// Data is private (wallet-signed access), not freely accessible to the public
isAccessibleForFree: false,
creator: { '@type': 'Organization', name: 'Endorphin', url: siteUrl },
publisher: { '@id': `${siteUrl}/#org` },
license: `${siteUrl}/en/terms`,
temporalCoverage: '2025-01-01/..',
}
export default function TradesPage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(tradesDataset) }}
/>
<Breadcrumbs items={[{ name: 'Trades', path: '/en/trades' }]} />
<TradesPageClient />
</>
)
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
return { props: { ...{}, seo: await generateMetadata() } }
}
+108
View File
@@ -0,0 +1,108 @@
import { type PostListResponse } from '@/lib/api'
import type { Metadata } from 'next'
import TrumpPageClient from '@/screens/trump/TrumpPageClient'
import Breadcrumbs from '@/components/seo/Breadcrumbs'
import { getInitialPostPage } from '@/lib/postPage'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
interface TrumpPageProps {
params: Promise<{ locale: string }>
}
async function generateMetadata({ params }: TrumpPageProps): Promise<Metadata> {
const locale = 'en'
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
return {
title: isZh ? 'Trump Truth Social 实时信号' : 'Trump Truth Social Signals',
description: isZh
? '实时抓取并打分 Donald Trump 的 Truth Social 帖子,3 秒内判断是 LONG、SHORT 还是噪音,并可选择在 Hyperliquid 上以逐仓模式自动执行。'
: 'Real-time AI scoring of every Trump Truth Social post. Classified as LONG, SHORT, or NOISE within 3 seconds. Optional auto-trade on Hyperliquid with isolated margin.',
keywords: isZh
? [
'Trump 加密信号',
'Truth Social 交易信号',
'特朗普发帖行情',
'Trump 自动交易',
'Hyperliquid 机器人',
'Trump 加密信号',
]
: [
'Trump crypto signal',
'Truth Social crypto',
'Trump Truth Social trading bot',
'Trump market signal',
'crypto auto-trader',
'Hyperliquid bot',
],
openGraph: {
title: isZh
? 'Trump Truth Social 实时信号 | Trump Alpha'
: 'Trump Truth Social Signals | Trump Alpha',
description: isZh
? '每条 Trump 帖子都在 3 秒内完成 AI 分类,输出方向、信心分和是否可执行,并支持 Hyperliquid 自动交易。'
: 'Every Trump post scored by AI in under 3 seconds. LONG, SHORT, or NOISE — with optional Hyperliquid auto-trade.',
},
alternates: {
canonical: `${siteUrl}/en/trump`,
languages: {
en: `${siteUrl}/en/trump`,
'x-default': `${siteUrl}/en/trump`,
},
},
}
}
// GEO: the live Trump signal feed is a dataset. schema.org/Dataset is highly
// cited by AI answer engines for "data / history of X" queries. Endorphin is
// the creator; the feed is free and continuously updated.
const trumpDataset = {
'@context': 'https://schema.org',
'@type': 'Dataset',
'@id': `${siteUrl}/en/trump#dataset`,
name: 'Trump Truth Social crypto-signal feed',
description:
'Time-stamped feed of Donald Trump Truth Social posts classified for crypto market impact (long / short / noise) with a confidence score and the realised short-term price move. Updated within seconds of each post.',
url: `${siteUrl}/en/trump`,
keywords: ['Trump', 'Truth Social', 'Bitcoin', 'crypto signal', 'sentiment'],
isAccessibleForFree: true,
creator: { '@type': 'Organization', name: 'Endorphin', url: siteUrl },
publisher: { '@id': `${siteUrl}/#org` },
license: `${siteUrl}/en/terms`,
measurementTechnique: 'NLP sentiment classification with confidence scoring',
temporalCoverage: '2025-01-01/..',
}
export default function TrumpPage({ initialData }: Awaited<ReturnType<typeof loadData>>) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(trumpDataset) }}
/>
<Breadcrumbs items={[{ name: 'Trump signals', path: '/en/trump' }]} />
<TrumpPageClient initialData={initialData} />
</>
)
}
async function loadData() {
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
source: 'truth',
legacyFallbackSource: 'truth',
})
return { initialData }
}
export const getServerSideProps: import('next').GetServerSideProps = async (context) => {
if (context.params?.locale !== 'en') return { notFound: true }
context.res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=60')
return {
props: {
...(await loadData()),
seo: await generateMetadata({ params: Promise.resolve({ locale: 'en' }) }),
},
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { AppProps } from 'next/app'
import { NextIntlClientProvider } from 'next-intl'
import Link from 'next/link'
import Script from 'next/script'
import Providers from '@/components/Providers'
import Navbar from '@/components/nav/Navbar'
import TradeAlertBanner from '@/components/ui/TradeAlertBanner'
import { PageHead } from '@/components/PageHead'
import { jsonLd } from '@/lib/structuredData'
import messages from '@/messages/en.json'
import '@rainbow-me/rainbowkit/styles.css'
import '@/styles/globals.css'
import '@/styles/landing.css'
export default function App({ Component, pageProps, router }: AppProps) {
const landing = router.pathname === '/'
return (
<NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
<PageHead seo={pageProps.seo} />
<Script
src="https://stats.bitnews.day/script.js"
data-website-id="708f06cc-3bd0-4b49-8e81-69289b7f1b42"
strategy="afterInteractive"
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\u003c') }}
/>
{landing ? (
<Component {...pageProps} />
) : (
<Providers>
<Navbar />
<TradeAlertBanner />
<main>
<Component {...pageProps} />
</main>
<footer className="flex flex-wrap items-center gap-4 border-t p-8 text-xs text-muted-foreground">
<span>© {new Date().getFullYear()} Trump Alpha — a research project by Endorphin</span>
{['methodology', 'glossary', 'case-studies', 'privacy', 'terms', 'contact'].map(
(path) => (
<Link key={path} href={`/en/${path}`}>
{path.replaceAll('-', ' ')}
</Link>
),
)}
</footer>
</Providers>
)}
</NextIntlClientProvider>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Apple touch icon (180×180) — also used as the PWA icon source.
*
* iOS Safari grabs <link rel="apple-touch-icon"> when "Add to Home Screen"
* is invoked. Next.js auto-wires this file to that link.
*/
import { ImageResponse } from 'next/og'
const size = { width: 180, height: 180 }
const contentType = 'image/png'
function AppleIcon() {
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
background: '#0a0907',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 132,
fontWeight: 700,
color: '#f5a524',
fontFamily: 'system-ui, sans-serif',
lineHeight: 1,
borderRadius: 24,
}}
>
α
</div>,
{ ...size },
)
}
export default async function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
const image = AppleIcon()
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(Buffer.from(await image.arrayBuffer()))
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Dynamic favicon. Renders the brand mark (α) on the dark brand background.
*
* Next.js auto-discovers this file and wires it into the page <head> as
* <link rel="icon"> for every route. No static asset needed in /public.
*
* For larger PWA install icons (192/512), see app/apple-icon.tsx and the
* manifest.ts entries that reference them.
*/
import { ImageResponse } from 'next/og'
const size = { width: 64, height: 64 }
const contentType = 'image/png'
function Icon() {
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
background: '#0a0907',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 44,
fontWeight: 700,
color: '#f5a524',
fontFamily: 'system-ui, sans-serif',
lineHeight: 1,
}}
>
α
</div>,
{ ...size },
)
}
export default async function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
const image = Icon()
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(Buffer.from(await image.arrayBuffer()))
}
+51
View File
@@ -0,0 +1,51 @@
import type { MetadataRoute } from 'next'
function manifest(): MetadataRoute.Manifest {
return {
name: 'Trump Alpha',
short_name: 'Trump Alpha',
description:
'Four crypto alpha feeds, one dashboard. Trump signals, BTC on-chain, KOL analysis, talks-vs-trades.',
start_url: '/en',
display: 'standalone',
background_color: '#0a0907',
theme_color: '#f5a524',
orientation: 'portrait-primary',
categories: ['finance', 'utilities'],
// Icons sourced from Next's dynamic generators (app/icon.tsx,
// app/apple-icon.tsx) — no static PNG asset required. `sizes: 'any'` lets
// the install prompt accept whatever resolution the OS asks for since
// ImageResponse renders at any size we declare in those files.
icons: [
{
src: '/icon',
sizes: 'any',
type: 'image/png',
purpose: 'any',
},
{
src: '/apple-icon',
sizes: '180x180',
type: 'image/png',
purpose: 'maskable',
},
],
screenshots: [
{
src: '/opengraph-image',
sizes: '1200x630',
type: 'image/png',
form_factor: 'wide',
label: 'Trump Alpha dashboard',
},
],
}
}
export default function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
res.setHeader('Content-Type', 'application/manifest+json')
res.json(manifest())
}
+179
View File
@@ -0,0 +1,179 @@
import { ImageResponse } from 'next/og'
const alt = 'Trump Alpha — the crypto signals that move price before the crowd, by Endorphin'
const size = { width: 1200, height: 630 }
const contentType = 'image/png'
function OGImage() {
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '64px 72px',
background: '#0a0907',
position: 'relative',
overflow: 'hidden',
fontFamily: 'system-ui, sans-serif',
}}
>
{/* Background grid */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(rgba(245,165,36,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(245,165,36,0.04) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
{/* Ambient glow */}
<div
style={{
position: 'absolute',
top: -200,
left: -200,
width: 800,
height: 800,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(245,165,36,0.12) 0%, transparent 70%)',
}}
/>
{/* Top-right corner badge */}
<div
style={{
position: 'absolute',
top: 48,
right: 72,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 14px',
borderRadius: 999,
background: 'rgba(245,165,36,0.12)',
border: '1px solid rgba(245,165,36,0.3)',
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: '#22c55e',
}}
/>
<span style={{ fontSize: 14, color: '#f5a524', fontWeight: 700, letterSpacing: '0.06em' }}>
LIVE
</span>
</div>
{/* Four signal pills */}
<div
style={{
display: 'flex',
gap: 12,
marginBottom: 40,
flexWrap: 'wrap',
}}
>
{[
{ label: 'Trump · Truth Social', color: '#f5a524' },
{ label: 'Macro Vibes', color: '#a78bfa' },
{ label: 'KOL Signal', color: '#22c55e' },
{ label: 'Talks vs Trades', color: '#ef4444' },
].map((p) => (
<div
key={p.label}
style={{
display: 'flex',
padding: '7px 16px',
borderRadius: 6,
background: 'rgba(255,255,255,0.05)',
border: `1px solid ${p.color}40`,
fontSize: 13,
fontWeight: 600,
color: p.color,
letterSpacing: '0.02em',
}}
>
{p.label}
</div>
))}
</div>
{/* Main title */}
<div
style={{
fontSize: 72,
fontWeight: 800,
lineHeight: 1.05,
color: '#f5f2ea',
marginBottom: 24,
letterSpacing: '-0.03em',
}}
>
Trump Alpha
</div>
{/* Sub */}
<div
style={{
fontSize: 24,
color: '#8a8578',
fontWeight: 400,
lineHeight: 1.5,
maxWidth: 700,
marginBottom: 0,
}}
>
The crypto signals that move price before the crowd.
<br />A research desk by Endorphin — public, live, timestamped.
</div>
{/* Bottom right stat strip */}
<div
style={{
position: 'absolute',
bottom: 52,
right: 72,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 6,
}}
>
{[
{ v: '<3s', l: 'post → position' },
{ v: '25', l: 'KOL feeds' },
{ v: '$0', l: 'platform fee' },
].map((s) => (
<div key={s.l} style={{ display: 'flex', gap: 10, alignItems: 'baseline' }}>
<span
style={{ fontSize: 22, fontWeight: 700, color: '#f5a524', fontFamily: 'monospace' }}
>
{s.v}
</span>
<span style={{ fontSize: 13, color: '#5c584e' }}>{s.l}</span>
</div>
))}
</div>
</div>,
{ ...size },
)
}
export default async function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
const image = OGImage()
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=86400')
res.send(Buffer.from(await image.arrayBuffer()))
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export const config = { api: { bodyParser: false, responseLimit: '8mb' } }
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const path = req.query.path
if (!Array.isArray(path) || path.some((p) => p === '..' || p.includes('/') || p.includes('\\')))
return res.status(400).json({ error: 'Invalid path' })
const base = process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
const target = new URL('/' + path.map(encodeURIComponent).join('/'), base)
const incoming = new URL(req.url ?? '/', 'http://localhost')
target.search = incoming.search
const headers = new Headers()
const hop = new Set([
'host',
'connection',
'transfer-encoding',
'content-length',
'keep-alive',
'upgrade',
'x-forwarded-host',
'x-forwarded-proto',
])
for (const [k, v] of Object.entries(req.headers))
if (v && !hop.has(k)) headers.set(k, Array.isArray(v) ? v.join(',') : v)
headers.set('accept-encoding', 'identity')
try {
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of req) {
const b = Buffer.from(chunk)
bytes += b.length
if (bytes > 1024 * 1024) return res.status(413).end()
chunks.push(b)
}
const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks)
const upstream = await fetch(target, {
method: req.method,
headers,
body,
signal: AbortSignal.timeout(30_000),
redirect: 'manual',
})
upstream.headers.forEach((v, k) => {
if (!hop.has(k) && k !== 'content-encoding') res.setHeader(k, v)
})
res.status(upstream.status).send(Buffer.from(await upstream.arrayBuffer()))
} catch {
res.status(502).json({ error: 'Upstream request failed' })
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { MetadataRoute } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
// Settings page is user-personalized (wallet address, HL keys, alert prefs).
// Disallow under every locale prefix in case any /zh URLs survive in the wild
// — i18n is shelved but the routes still resolve.
const SETTINGS_DENY = ['/api/', '/_next/', '/en/settings', '/zh/settings']
function robots(): MetadataRoute.Robots {
return {
rules: [
// Default: allow everything except private pages
{
userAgent: '*',
allow: '/',
disallow: SETTINGS_DENY,
},
// ── Standard search crawlers ──────────────────────────────────────────
{ userAgent: 'Googlebot', allow: '/' },
{ userAgent: 'Bingbot', allow: '/' },
{ userAgent: 'Slurp', allow: '/' }, // Yahoo
{ userAgent: 'DuckDuckBot', allow: '/' },
{ userAgent: 'YandexBot', allow: '/' },
// ── AI / LLM crawlers (GEO) ───────────────────────────────────────────
// OpenAI — ChatGPT Browse + training
{ userAgent: 'GPTBot', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'ChatGPT-User', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'OAI-SearchBot', allow: '/', disallow: SETTINGS_DENY },
// Anthropic — Claude.ai + training (settings are already private so
// we use a shorter deny list to let anthropic-ai see more content)
{ userAgent: 'ClaudeBot', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'anthropic-ai', allow: '/', disallow: ['/api/', '/_next/'] },
// Perplexity AI
{ userAgent: 'PerplexityBot', allow: '/', disallow: SETTINGS_DENY },
// Google — Gemini / AI Overviews / Bard training
{ userAgent: 'Google-Extended', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'Googlebot-Extended', allow: '/', disallow: SETTINGS_DENY },
// Meta AI (Llama, Meta AI assistant)
{ userAgent: 'meta-externalagent', allow: '/', disallow: SETTINGS_DENY },
{ userAgent: 'FacebookBot', allow: '/', disallow: SETTINGS_DENY },
// Apple (Siri, AI features in Safari)
{ userAgent: 'Applebot', allow: '/' },
{ userAgent: 'Applebot-Extended', allow: '/', disallow: SETTINGS_DENY },
// Cohere — Command R, enterprise LLMs
{ userAgent: 'cohere-ai', allow: '/', disallow: SETTINGS_DENY },
// Mistral / Le Chat
{ userAgent: 'MistralAI-User', allow: '/', disallow: SETTINGS_DENY },
// You.com AI search
{ userAgent: 'YouBot', allow: '/', disallow: SETTINGS_DENY },
// Brave AI (Leo assistant)
{ userAgent: 'Brave', allow: '/' },
// Common Crawl — feeds many open-source LLM training datasets
{ userAgent: 'CCBot', allow: '/', disallow: SETTINGS_DENY },
],
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
}
}
export default function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
res.setHeader('Content-Type', 'text/plain')
const data = robots()
const rules = Array.isArray(data.rules) ? data.rules : [data.rules]
res.send(
rules
.map(
(r) =>
'User-agent: ' +
(r.userAgent ?? '*') +
'\n' +
[r.allow]
.flat()
.filter(Boolean)
.map((p) => 'Allow: ' + p)
.join('\n') +
'\n' +
[r.disallow]
.flat()
.filter(Boolean)
.map((p) => 'Disallow: ' + p)
.join('\n'),
)
.join('\n') +
'\nSitemap: ' +
data.sitemap,
)
}
+96
View File
@@ -0,0 +1,96 @@
import type { MetadataRoute } from 'next'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
// In production, warn loudly but don't crash — the sitemap will use the
// fallback URL. Set NEXT_PUBLIC_SITE_URL in your deployment env to get the
// correct absolute URLs in sitemap.xml.
if (!siteUrl && process.env.NODE_ENV === 'production') {
console.warn(
'[sitemap] NEXT_PUBLIC_SITE_URL is not set — sitemap will use https://trumpsignal.com as fallback. ' +
'Set this env var in your deployment to avoid incorrect sitemap URLs.',
)
}
const base = (siteUrl || 'https://trumpsignal.com').replace(/\/$/, '')
const routes: Array<{
path: string
priority: number
freq: MetadataRoute.Sitemap[number]['changeFrequency']
}> = [
{ path: '', priority: 1.0, freq: 'hourly' },
{ path: '/trump', priority: 0.9, freq: 'hourly' },
{ path: '/macro', priority: 0.9, freq: 'daily' },
{ path: '/kol', priority: 0.9, freq: 'daily' },
{ path: '/trades', priority: 0.8, freq: 'daily' },
{ path: '/analytics', priority: 0.7, freq: 'daily' },
{ path: '/methodology', priority: 0.8, freq: 'monthly' },
{ path: '/glossary', priority: 0.8, freq: 'monthly' },
{ path: '/case-studies', priority: 0.8, freq: 'monthly' },
// NOTE: /settings is intentionally NOT listed — it's user-personalized and
// robots.ts disallows it. Listing it here while disallowing in robots is a
// self-contradicting signal to crawlers. Keep private pages out of both.
{ path: '/privacy', priority: 0.3, freq: 'yearly' },
{ path: '/terms', priority: 0.3, freq: 'yearly' },
{ path: '/contact', priority: 0.4, freq: 'monthly' },
]
// i18n shelved — only emit English routes. Add 'zh' back when content is
// actually translated (otherwise Google flags duplicate content under hreflang).
const locales = ['en']
function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
const entries: MetadataRoute.Sitemap = []
// Root landing (not locale-scoped)
entries.push({
url: base,
lastModified: now,
changeFrequency: 'hourly',
priority: 1.0,
})
for (const locale of locales) {
for (const r of routes) {
entries.push({
url: `${base}/${locale}${r.path}`,
lastModified: now,
changeFrequency: r.freq,
priority: r.priority,
})
}
}
return entries
}
export default function handler(
_req: import('next').NextApiRequest,
res: import('next').NextApiResponse,
) {
res.setHeader('Content-Type', 'application/xml')
res.send(
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
sitemap()
.map(
(x) =>
'<url><loc>' +
String(x.url).replaceAll('&', '&amp;').replaceAll('<', '&lt;') +
'</loc>' +
(x.lastModified
? '<lastmod>' +
(x.lastModified instanceof Date
? x.lastModified.toISOString()
: String(x.lastModified)) +
'</lastmod>'
: '') +
(x.changeFrequency ? '<changefreq>' + x.changeFrequency + '</changefreq>' : '') +
(x.priority != null ? '<priority>' + x.priority + '</priority>' : '') +
'</url>',
)
.join('') +
'</urlset>',
)
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+111
View File
@@ -0,0 +1,111 @@
# Trump Alpha — Full Reference (by Endorphin)
> Expanded companion to /llms.txt. This single document inlines the full
> methodology, the glossary, and the documented case studies with their
> evidence sources, so an AI assistant can answer detailed questions in one
> fetch. For the concise index see https://trumpsignal.com/llms.txt
>
> Trump Alpha is a crypto research project by Endorphin, a research desk. It
> tracks four uncorrelated signals that move markets before consensus catches
> up. Every signal is public and timestamped. Free to read; an optional
> non-custodial Hyperliquid auto-trader is available for the Trump signal only.
## Who runs this
Endorphin is the research desk behind Trump Alpha. Positioning is deliberately
research-first: the site documents live production logic, publishes its
track record openly, and timestamps every call. It is not a paid signal group,
a course, or a "guru" account.
## The four signal engines (full logic)
### 1. Trump Truth Social Signal (real-time event)
Watches Donald Trump's Truth Social feed in real time. Within ~15 seconds of a
new post, an NLP model classifies it as LONG (bullish crypto), SHORT (bearish),
or NOISE (off-topic), with a 0–100 confidence score. Post-to-classification
runs in under 3 seconds. Posts that name specific assets and tie them to policy
score highest. Only posts ≥ 88 confidence can trigger the optional auto-trader.
Failure mode: vague political posts with no asset/policy content score low by
design; sarcasm and quote-reposts are the hardest cases.
### 2. Macro Vibes (crypto macro regime)
Eight free public macro indicators in one view: AHR999, Fear & Greed Index, BTC
dominance, ETH/BTC ratio, total stablecoin supply, US spot Bitcoin ETF daily net
flow, BTC perpetual open interest, and the Altcoin Season Index. Plus two trade
triggers:
- BTC Macro Bottom (long-only): fires when ≥ 2 of 3 agree — AHR999 < 0.45,
price ≤ 200-week MA × 1.05, Pi Cycle Bottom. Fires 2–4× per multi-year cycle.
- BTC Funding Reversal: fires when 30-day cumulative perp funding crosses ±3%
AND recent cycles start cooling (mean-reversion against crowded positioning).
Manage-only: alerts surface; the user opens on Hyperliquid and the bot then
manages the exit (5-rung stop ladder, de-risk, pyramid, peak-trail). No auto-open.
### 3. KOL talks-vs-trades divergence (highest conviction)
25 crypto KOL feeds ingested daily via RSS. An NLP step extracts ticker(s),
directional stance, and conviction. A separate on-chain step diffs each KOL's
Ethereum wallet. A DIVERGENCE fires when public stance and wallet action
disagree within a ±7-day window (e.g. publicly bullish ETH while selling ETH).
On-chain action is treated as ground truth. This is the platform's
highest-conviction category. Alert-only — never auto-trades.
### 4. BTC Funding Rate Reversal
Lives inside Macro Vibes. Monitors BTC perpetual funding at Binance. Extreme
30-day cumulative funding (> ±3%) historically precedes mean reversion; the
scanner bets against the crowded side once rates start cooling. Alert-only.
### Optional: Hyperliquid auto-trader (Trump signal only)
Non-custodial execution layer using a trade-only Hyperliquid API key (cannot
withdraw funds). User controls leverage (default 3×), position size, TP/SL,
daily budget cap, active-hours window, and minimum confidence. System 1 (Trump
scalp) is the only engine that auto-opens; everything else is alert/manage-only.
## Glossary (key terms)
- AHR999: composite BTC valuation indicator (price, geometric-mean price, and an
exponential growth fit). < 0.45 = deep-value accumulation zone; > 1.2 =
expensive relative to the growth trajectory.
- Pi Cycle Bottom: 150-day EMA ≤ 471-day SMA × 0.745. Historically pinpointed
BTC cycle bottoms within ~2 weeks in 2015, 2018, and 2022.
- 200-week moving average: long-cycle BTC support; price below or near it has
marked historical accumulation regimes.
- Talks-vs-trades divergence: a KOL's public stance and on-chain wallet behaviour
point in opposite directions on the same asset within ±7 days.
- Funding reversal: mean-reversion signal when 30-day cumulative BTC perp funding
crosses ±3% and the crowded side starts cooling.
## Documented case studies (with evidence sources)
These are evidence, not return promises. Each answers: what happened, how the
market reacted, and why it supports a specific engine.
- Strategic Crypto Reserve announcement (Mar 2, 2025, Trump Truth Social):
Trump confirmed a U.S. Strategic Crypto Reserve including BTC, ETH, SOL, XRP,
ADA. BTC +8.2%, ETH +10%+ within 24h. Supports a high-conviction LONG when a
post names specific assets tied to policy.
Source: cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html
- $6.8M pre-positioning profit around the reserve post (Mar 2025, on-chain):
An anonymous address built a large leveraged BTC/ETH position around the
announcement and reportedly closed it same-day for ~$6.8M. Shows speed is edge
in event-driven trading.
- Tariff escalation post (Apr 2025, Trump Truth Social): a tariff-escalation
post produced a risk-off move, BTC -4.1% in ~4h. Supports the need for a real
SHORT branch — not every Trump post is bullish.
Source: coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week
- ETH talks-vs-trades divergence (illustrative composite): a tracked KOL
published a bullish ETH thesis then reduced ~$180k ETH within five days —
classic divergence between narrative and positioning.
- FTX washout zone (Nov 2022, BTC macro bottom reference): BTC reached ~$15.5k
and recovered toward ~$69k within ~18 months (+345%). The regime the BTC
macro-bottom engine is built to identify via AHR999 + 200-week MA + Pi Cycle.
## URLs
- Home: https://trumpsignal.com
- Trump signals: https://trumpsignal.com/en/trump
- Macro Vibes: https://trumpsignal.com/en/macro
- KOL talks-vs-trades: https://trumpsignal.com/en/kol
- Trade history: https://trumpsignal.com/en/trades
- Analytics / track record: https://trumpsignal.com/en/analytics
- Methodology: https://trumpsignal.com/en/methodology
- Glossary: https://trumpsignal.com/en/glossary
- Case studies: https://trumpsignal.com/en/case-studies
+82
View File
@@ -0,0 +1,82 @@
# Trump Alpha
> A crypto research project by Endorphin, a research desk. Tracks four uncorrelated signals that move markets before consensus catches up: Trump Truth Social sentiment, Macro Vibes (crypto macro regime dashboard), KOL Substack/podcast signals, and talks-vs-trades on-chain divergence. Every signal is public and timestamped.
>
> Full reference (methodology + glossary + case studies inlined): https://trumpsignal.com/llms-full.txt
## What this site does
Trump Alpha is Endorphin's public signal desk: four independent signal engines and one macro context view in one live dashboard. All four dashboards are free to read — no account required, every call timestamped.
1. **Trump Truth Social Signal** — Scrapes Trump's Truth Social posts within 15 seconds of publish. AI classifies each post as LONG (bullish crypto), SHORT (bearish), or NOISE (off-topic) with a confidence score 0–100. Only posts scoring ≥ 88 confidence trigger the optional auto-trader. The entire pipeline from post to classification runs in under 3 seconds. Optional: auto-execute on Hyperliquid with isolated margin, automatic TP/SL, and six user-defined safety limits (leverage cap, position size, daily budget, active-hours window, confidence floor, stop-loss floor).
2. **Macro Vibes** — The crypto macro-regime dashboard. Surfaces eight free public macro signals in one view: AHR999, Fear & Greed Index, BTC dominance, ETH/BTC ratio, total stablecoin supply, US spot Bitcoin ETF daily net flow, BTC perpetual open interest, and the Altcoin Season Index — plus two trade triggers:
- **BTC Macro Bottom** (long-only): fires when ≥ 2 of 3 agree — AHR999 < 0.45, price ≤ 200-week MA × 1.05, Pi Cycle Bottom. Fires 2–4 times per multi-year cycle.
- **BTC Funding Reversal**: fires when 30-day cumulative perp funding crosses ±3% AND the most recent cycles start cooling (mean-reversion bet against crowded positioning).
3. **KOL Signal** — 25 crypto KOL feeds ingested daily via RSS: Arthur Hayes (BitMEX co-founder), Delphi Digital, Bankless, Empire (Jason Yanowitz + Santiago Santos), Unchained (Laura Shin), 0xResearch (Blockworks), Lightspeed (Blockworks), Pomp (Anthony Pompliano), The Defiant, Reflexivity Research (Will Clemente), Bell Curve (Multicoin Capital), The Scoop (The Block / Frank Chaparro), TFTC (Marty Bent), checkmate (Glassnode), coinmetrics, willywoo (Willy Woo), glassnode, Lyn Alden (Lyn Alden Investment Strategy), The DeFi Edge, Bitcoin Magazine, Deribit Insights, Bitfinex Alpha, Forward Guidance, and more. AI extracts: ticker(s) mentioned, directional stance (buy/sell/bullish/bearish/reduce/mention), and conviction score.
4. **Talks-vs-Trades Divergence** — The platform's highest-conviction signal. Cross-references each KOL's public posts against their on-chain Ethereum wallet changes within a ±7-day window. A DIVERGENCE fires when a KOL is publicly bullish but their wallet is selling (or vice versa). An ALIGNMENT fires when public stance and on-chain action agree. On-chain action is treated as ground truth — talk is cheap, wallet movements are not.
5. **Funding Rate Reversal** — Lives inside Macro Vibes. Monitors BTC perpetual funding rate at Binance. Extreme positive or negative 30-day cumulative funding (> ±3%) historically precedes mean-reversion; the scanner bets against the crowded side when rates start cooling.
6. **Hyperliquid Auto-Trader** — Optional execution layer for the Trump signal. Non-custodial: uses a trade-only Hyperliquid API key (cannot withdraw funds). You control: leverage (default 3×), position size, TP/SL percentages, daily budget cap, active-hours trading window, and minimum confidence threshold.
## Key concepts defined
**AHR999**: A composite Bitcoin valuation indicator combining price, the geometric mean of historical price, and an exponential growth model fit. Below 0.45 marks the deep-value / accumulation zone; above 1.2 means BTC is expensive relative to its growth trajectory.
**200-week Moving Average (200WMA)**: Long-cycle structural support. Every Bitcoin bear cycle since 2013 has bottomed within ±5% of this level. Used as one of three macro-bottom trigger conditions.
**Pi Cycle Bottom**: 150-day EMA crossing to ≤ 471-day SMA × 0.745. A late-cycle capitulation signal that historically pinpointed the 2015, 2018, and 2022 BTC cycle bottoms within a 2-week window.
**Talks-vs-Trades divergence**: Comparing a KOL's public verbal position on an asset against what their on-chain wallet actually does within the same ±7-day window. The divergence signal (say one thing, do another) is treated as the higher-conviction indicator — on-chain behavior is harder to fake than a tweet or newsletter.
**KOL (Key Opinion Leader)**: In crypto, influential analysts, fund managers, podcast hosts, and content creators whose public views measurably move retail sentiment. Trump Alpha tracks 25 of them across Substack, podcast RSS, and blog feeds.
**Hyperliquid**: A decentralized perpetual futures exchange. Trump Alpha's auto-trader executes all positions there. API keys are trade-only (no withdrawal permissions) and are stored server-side encrypted.
**Isolated margin**: Each trade uses only the capital allocated to it. A losing trade cannot drain other positions or your full wallet — it is isolated to its own margin bucket.
## Who this is for
- Crypto traders who want to react to Trump's posts faster than manual monitoring allows
- Bitcoin cycle investors watching for macro-bottom confluence to size into long-term positions
- Sophisticated traders who want to know what prominent KOLs are actually doing on-chain vs. what they say publicly
- Anyone building or refining a systematic approach to crypto that uses multiple uncorrelated signals
## What makes Trump Alpha different
- **Speed**: Trump post → AI classification → optional trade in < 3 seconds. Manual monitoring can't compete.
- **On-chain ground truth**: The talks-vs-trades module treats wallet movements as more reliable than public statements. Most signal platforms only track what people say.
- **Four uncorrelated sources**: Trump macro sentiment, Bitcoin cycle indicators, KOL narratives, and funding extremes don't move together — combining them reduces false signals.
- **No custody**: Your API key can trade but cannot withdraw. Trump Alpha never touches your actual funds.
## Pricing
Free to read all four dashboards. No subscription required. No account needed to view signals.
The optional Hyperliquid auto-trader requires your own Hyperliquid account and a trade-only API key.
## Technology
- Backend: Python 3.9+ / FastAPI / SQLite (dev) + PostgreSQL (prod)
- AI signal extraction: Claude (Anthropic) — Haiku for KOL batch analysis, custom model for Trump live scoring
- On-chain data: Etherscan API (ERC-20 wallet tracking) + Hyperliquid public API (perp positions)
- Price data: Binance WebSocket (BTC/ETH/SOL/TRUMP/BNB/DOGE/LINK/AAVE) + Hyperliquid allMids (HYPE/PURR)
- Frontend: Next.js 16 / TypeScript / wagmi (Ethereum wallet connect)
- Alerts: Telegram bot with per-user signal preferences
## URLs
- Homepage / landing page: https://trumpsignal.com
- Trump Truth Social signal dashboard: https://trumpsignal.com/en/trump
- Macro Vibes (BTC cycle + macro + funding): https://trumpsignal.com/en/macro
- KOL signals & talks-vs-trades divergence: https://trumpsignal.com/en/kol
- Signal methodology (full technical explanation): https://trumpsignal.com/en/methodology
- Glossary (AHR999, Pi Cycle, MVRV-Z, KOL, divergence...): https://trumpsignal.com/en/glossary
- Case studies (documented Trump crypto post events with price impact): https://trumpsignal.com/en/case-studies
- FAQ: https://trumpsignal.com/#faq
## Contact
Site: https://trumpsignal.com
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,573 @@
'use client'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getTrades, getSignalAccuracy } from '@/lib/api'
import type { BotTrade } from '@/types'
import type { SignalAccuracy } from '@/lib/api'
import {
getCachedViewEnvelope,
getOrCreateViewEnvelope,
type SignedEnvelope,
} from '@/lib/signedRequest'
import { swrFetch } from '@/lib/cache'
import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
type Period = '7d' | '30d' | '90d' | 'All'
function fmtMoney(n: number) {
if (n == null || isNaN(n)) return '—'
const abs = Math.abs(n)
const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 })
if (n < 0) return '-$' + s
if (n > 0) return '+$' + s
return '$' + s
}
function fmtHold(s: number) {
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
}
function fmtAccuracyPct(pct: number | null | undefined) {
return pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
}
function inPeriod(iso: string | null, period: Period) {
if (!iso) return false // trades without closed_at are excluded from all windows
if (period === 'All') return true
const days = Number.parseInt(period, 10)
if (Number.isNaN(days)) return true
const time = new Date(iso).getTime()
return time >= Date.now() - days * 24 * 60 * 60 * 1000
}
function calcDrawdownPct(trades: BotTrade[]) {
const ordered = [...trades]
.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined && t.closed_at !== null)
.sort((a, b) => new Date(a.closed_at!).getTime() - new Date(b.closed_at!).getTime())
let equity = 0
let peak = 0
let maxDrawdownPct = 0
for (const trade of ordered) {
equity += trade.pnl_usd as number
peak = Math.max(peak, equity)
if (peak > 0) {
maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100)
}
}
return maxDrawdownPct
}
export default function AnalyticsPageClient() {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const [trades, setTrades] = useState<BotTrade[]>([])
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
const [period, setPeriod] = useState<Period>('30d')
const [tradeMode, setTradeMode] = useState<'live' | 'paper'>('live')
const [privateLocked, setPrivateLocked] = useState(false)
const [unlocking, setUnlocking] = useState(false)
const [unlockErr, setUnlockErr] = useState('')
const aliveRef = useRef(true)
useEffect(() => {
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// genRef guards loadAll against stale-closure wallet-switch races.
// snapAddr === address inside an async function is a stale-closure trap:
// both refer to the same closed-over value at render time, so the check
// is always true even when the wallet has changed. genRef is a mutable
// ref that any closure can read to detect it has been superseded.
const genRef = useRef(0)
useEffect(() => {
genRef.current++
}, [address])
// `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button
// load private data without a detour through the Settings page. Public
// signal-accuracy always loads regardless.
//
// All displayed stats are computed locally from this single trades list on
// ONE basis (closed_at, see inPeriod) for EVERY time window — we no longer
// mix in the backend /performance aggregate for 30d, which used a different
// basis (it filtered opened_at and capped at 30d) and produced numbers that
// disagreed with the closed_at-based metric grid on the same screen. Limit
// raised to 500 so the local computation covers ample history.
async function loadAll(forcedEnv?: SignedEnvelope) {
const accuracyPromise = swrFetch('signal-accuracy', 10 * 60_000, () =>
getSignalAccuracy(),
).catch(() => null)
if (!address || !isConnected) {
setTrades([])
setPrivateLocked(false)
const a = await accuracyPromise
if (aliveRef.current) setAccuracy(a)
return
}
const snapAddr = address
const gen = genRef.current
const tradesEnv =
getCachedViewEnvelope('view_trades', snapAddr) ??
forcedEnv ??
getCachedViewEnvelope('view_user', snapAddr)
if (aliveRef.current) setPrivateLocked(!tradesEnv)
try {
const [t, a] = await Promise.all([
tradesEnv ? getTrades(snapAddr, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
accuracyPromise,
])
// Discard if unmounted or if a newer loadAll call has started (wallet/tab change).
if (aliveRef.current && gen === genRef.current) {
setTrades(t)
setAccuracy(a)
}
} catch {
if (aliveRef.current && gen === genRef.current) setTrades([])
}
}
useEffect(() => {
// B28/B36: clear stale previous-wallet data immediately before the async
// fetch so the UI never shows another wallet's private P&L.
setTrades([])
setPrivateLocked(false)
// Navigation only uses a cached envelope — never auto-popup the wallet.
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// Mint a view_user envelope (one signature) — unlocks both /performance and
// /trades (each accepts view_user as fallback) — then reload.
async function handleUnlock() {
if (!address) return
setUnlocking(true)
setUnlockErr('')
try {
const env = await getOrCreateViewEnvelope({
action: 'view_user',
wallet: address,
signMessageAsync,
})
await loadAll(env)
} catch (e) {
const msg = e instanceof Error ? e.message : 'Signature cancelled'
if (aliveRef.current) setUnlockErr(/reject|denied|cancel/i.test(msg) ? '' : msg)
} finally {
if (aliveRef.current) setUnlocking(false)
}
}
// CRITICAL: never mix paper (simulated) and live (real-money) P&L into one
// number. is_paper comes from the backend (hl_order_id == "paper"). Default
// to LIVE — this page is the "did the bot make REAL money" view. A Paper/Live
// toggle appears only when the wallet has both kinds of trades; otherwise we
// auto-pick whichever set exists so a paper-only user still sees their data
// (clearly labelled SIMULATED).
const liveTrades = useMemo(() => trades.filter((t) => !t.is_paper), [trades])
const paperTrades = useMemo(() => trades.filter((t) => !!t.is_paper), [trades])
const hasBoth = liveTrades.length > 0 && paperTrades.length > 0
const effectiveMode: 'live' | 'paper' = hasBoth
? tradeMode
: liveTrades.length > 0
? 'live'
: paperTrades.length > 0
? 'paper'
: 'live'
const modeTrades = effectiveMode === 'paper' ? paperTrades : liveTrades
const isPaperView = effectiveMode === 'paper'
const filteredTrades = modeTrades.filter((trade) => inPeriod(trade.closed_at, period))
const pricedTrades = filteredTrades.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined)
const pnls = pricedTrades.map((t) => t.pnl_usd as number)
const totalPnl = pnls.reduce((s, p) => s + p, 0)
const wins = pnls.filter((p) => p > 0).length
const winRate = pricedTrades.length ? (wins / pricedTrades.length) * 100 : 0
const bestTrade = pnls.length ? Math.max(...pnls) : 0
const worstTrade = pnls.length ? Math.min(...pnls) : 0
const avgTrade = pnls.length ? totalPnl / pnls.length : 0
const heldTrades = filteredTrades.filter((t) => t.hold_seconds !== null)
const avgHold = heldTrades.length
? heldTrades.reduce((sum, trade) => sum + (trade.hold_seconds ?? 0), 0) / heldTrades.length
: 0
// One basis for every window: derive from the closed_at-filtered trades.
const maxDrawdown = calcDrawdownPct(filteredTrades)
const summaryPnl = totalPnl
const metrics = [
{
k: isZh ? '最大回撤' : 'Max drawdown',
v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—',
sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough',
down: true,
tip: 'Largest equity drop from a previous high. Tells you the worst losing streak you would have lived through.',
},
{
k: isZh ? '总交易数' : 'Total trades',
v: String(filteredTrades.length || '—'),
sub: isZh ? '已平仓机器人交易' : 'Closed bot executions',
tip: 'How many closed positions in this window. Open trades not counted.',
},
{
k: isZh ? '平均持仓时间' : 'Avg hold time',
v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—',
sub: isZh ? '按单笔计算' : 'Per trade',
tip: 'Entry → exit duration averaged across every closed trade.',
},
{
k: isZh ? '平均单笔盈亏' : 'Avg trade P&L',
v: pnls.length ? fmtMoney(avgTrade) : '—',
sub: isZh ? '每笔交易均值' : 'Mean per trade',
up: avgTrade > 0,
tip: 'Total P&L ÷ number of trades. Positive = the strategy has edge per trade.',
},
{
k: isZh ? '最佳单笔' : 'Best trade',
v: pnls.length ? fmtMoney(bestTrade) : '—',
sub: isZh ? '单笔最大盈利' : 'Largest single win',
up: true,
tip: 'Biggest realized gain on one trade in this window.',
},
{
k: isZh ? '最差单笔' : 'Worst trade',
v: pnls.length ? fmtMoney(worstTrade) : '—',
sub: isZh ? '单笔最大亏损' : 'Largest single loss',
down: true,
tip: 'Biggest realized loss on one trade. Should be bounded by your stop-loss setting.',
},
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '分析面板' : 'Analytics'}</h1>
<PageHint>
P&amp;L · win rate · drawdown · signal accuracy — pick a time window on the right.
</PageHint>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
{hasBoth && (
<div className="nav-tabs">
{(['live', 'paper'] as const).map((m) => (
<button
key={m}
className={`nav-tab ${tradeMode === m ? 'active' : ''}`}
onClick={() => setTradeMode(m)}
>
{m === 'live' ? '💰 Live' : '📝 Paper'}
</button>
))}
</div>
)}
<div className="nav-tabs">
{(['7d', '30d', '90d', 'All'] as const).map((p) => (
<button
key={p}
className={`nav-tab ${period === p ? 'active' : ''}`}
onClick={() => setPeriod(p)}
>
{p}
</button>
))}
</div>
</div>
</div>
{/* Signal accuracy — always public, shown first so non-connected visitors
can immediately see proof of signal quality without needing to log in. */}
{accuracy && (
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: 12,
marginBottom: 14,
flexWrap: 'wrap',
}}
>
<div className="tiny">AI Signal Accuracy</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>
{accuracy.total_directional_signals} directional signals measured
</span>
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>
Public · no login needed
</span>
</div>
{(() => {
// One full-width block per measurement window. Big overall number
// + a hit-rate bar (50% tick = coin-flip baseline) + per-direction
// breakdown. `repeat(3, 1fr)` stretches across the card so no
// whitespace is stranded on wide screens.
const WINDOWS = ['m5', 'm15', 'm1h'] as const
const WINDOW_LABEL = {
m5: 'After 5 min',
m15: 'After 15 min',
m1h: 'After 1 hour',
} as const
const pctColor = (pct: number) =>
pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
const fmtBreakdown = (pct: number | null, sparse: boolean) =>
sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
// auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the
// viewport on phones and forced the whole page to scroll sideways.
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))',
gap: 12,
}}
>
{WINDOWS.map((w) => {
const pct = accuracy.overall[w].accuracy_pct
const has = pct != null && !Number.isNaN(pct)
return (
<div
key={w}
style={{
background: 'var(--surface-2)',
borderRadius: 10,
padding: '14px 16px',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--ink-3)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
display: 'flex',
alignItems: 'center',
}}
>
{WINDOW_LABEL[w]}
<InfoTip
text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`}
placement="top"
/>
</div>
<div
className="mono"
style={{
fontSize: 30,
fontWeight: 600,
letterSpacing: '-0.01em',
marginTop: 8,
color: has ? pctColor(pct) : 'var(--ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
{has ? `${pct.toFixed(0)}%` : '—'}
</div>
{/* hit-rate bar with a 50% coin-flip tick */}
<div
style={{
position: 'relative',
height: 6,
background: 'var(--surface-3)',
borderRadius: 999,
marginTop: 10,
overflow: 'hidden',
}}
>
{has && (
<div
style={{
position: 'absolute',
inset: 0,
width: `${Math.min(100, Math.max(0, pct))}%`,
background: pctColor(pct),
borderRadius: 999,
opacity: 0.75,
}}
/>
)}
<div
style={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: 1.5,
background: 'var(--ink-4)',
opacity: 0.55,
}}
/>
</div>
<div
style={{
display: 'flex',
gap: 16,
marginTop: 12,
fontSize: 12,
color: 'var(--ink-3)',
flexWrap: 'wrap',
}}
>
{Object.entries(accuracy.by_signal).map(([sig, d]) => {
const sparse = d[w].checked < 2
const v = fmtBreakdown(d[w].accuracy_pct, sparse)
const label =
sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
return (
<span key={sig} style={{ whiteSpace: 'nowrap' }}>
{label}{' '}
<strong
style={{
color: sparse ? 'var(--ink-4)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}
>
{v}
</strong>
<span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
</span>
)
})}
</div>
</div>
)
})}
</div>
)
})()}
</div>
)}
{isPaperView && filteredTrades.length > 0 && (
<div
className="card"
style={{
padding: '10px 16px',
marginBottom: 16,
fontSize: 12,
fontWeight: 600,
background: 'var(--amber-soft)',
borderColor: 'color-mix(in oklab, var(--amber) 30%, var(--line))',
color: 'var(--amber-ink, var(--ink))',
}}
>
📝 Showing SIMULATED (paper) performance — these are not real-money results.
{hasBoth && ' Switch to 💰 Live above for actual P&L.'}
</div>
)}
{privateLocked && (
<div className="card" style={{ padding: 16, marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Your performance data is private.</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
Sign once to unlock your P&amp;L and trade history on this device (valid a few minutes —
no transaction, no gas). Signal-accuracy stats below are always public.
</div>
<button
className="btn amber"
style={{ fontSize: 13, padding: '8px 16px', marginTop: 12 }}
disabled={unlocking}
onClick={handleUnlock}
>
{unlocking ? 'Waiting for signature…' : 'Unlock performance'}
</button>
{unlockErr && (
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>● {unlockErr}</div>
)}
</div>
)}
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<div className="tiny">
{isZh ? '表现' : 'Performance'} · {period}
</div>
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
</div>
{/* Win-rate chip only when there are trades to rate — a red
"0.0% win rate" next to an em-dash P&L misreads as a losing
record when the wallet simply has no data. */}
{pricedTrades.length > 0 && (
<div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>
{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}
</span>
</div>
)}
{filteredTrades.length === 0 && (
<div
style={{
fontSize: 12,
color: 'var(--ink-3)',
marginTop: 12,
lineHeight: 1.6,
maxWidth: 520,
}}
>
No closed bot trades in this window. P&amp;L, win rate and the per-trade metrics
below fill in once auto-trade executions close — or pick a longer window on the top
right.
</div>
)}
</div>
</div>
</div>
<div className="metric-grid" style={{ marginBottom: 20 }}>
{metrics.map((m) => (
<div key={m.k} className="card" style={{ padding: 20, overflow: 'visible' }}>
<div
className="tiny"
style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}
>
{m.k}
{m.tip && <InfoTip text={m.tip} placement="top" width={240} />}
</div>
<div
className={`mono ${m.up ? 'delta up' : m.down ? 'delta down' : ''}`}
style={{ fontSize: 24, fontWeight: 500, letterSpacing: '-0.01em' }}
>
{m.v}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{m.sub}</div>
</div>
))}
</div>
{/* Signal accuracy is now shown at the top of the page (public, no login needed).
Removed duplicate block here to avoid showing the same data twice. */}
{/* Empty-state card renders only for states no other element explains:
- "window empty but trades exist" → Performance hero's inline note
- privateLocked → the unlock card at the top of the page */}
{!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>
{!isConnected || !address
? isZh
? '连接钱包以加载你的分析数据。'
: 'Connect your wallet to load your analytics.'
: isZh
? '还没有交易数据。机器人开始执行后,这里会自动出现统计。'
: 'No trade data yet. The bot will populate analytics once it starts executing.'}
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,164 @@
'use client'
import { useState, useEffect } from 'react'
import type { TrumpPost } from '@/types'
import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api'
import { hasCached, swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import PageHint from '@/components/ui/PageHint'
import Pagination from '@/components/ui/Pagination'
import { buildArchiveFallbackResponse } from '@/lib/postPage'
const ARCHIVE_PAGE_SIZE = 30
interface ArchivePageClientProps {
initialData?: PostListResponse | null
}
export default function ArchivePageClient({ initialData = null }: ArchivePageClientProps) {
const [posts, setPosts] = useState<TrumpPost[]>(initialData?.items ?? [])
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
const [sourceCounts, setSourceCounts] = useState(initialData?.source_counts ?? [])
const [loading, setLoading] = useState(initialData === null)
const [loadErr, setLoadErr] = useState('')
const [src, setSrc] = useState<string>('all')
const [archivePage, setArchivePage] = useState(1)
useEffect(() => {
const key = `archive-page-${archivePage}-src-${src}`
setLoadErr('')
setLoading(posts.length === 0 && !hasCached(key))
swrFetch(
key,
3 * 60_000,
() =>
getPostsPage(ARCHIVE_PAGE_SIZE, archivePage, undefined, {
archiveOnly: true,
sourceIn: src === 'all' ? undefined : [src],
}),
(fresh) => {
setPosts(fresh.items)
setTotalPosts(fresh.total)
setSourceCounts(fresh.source_counts)
},
)
.then((r) => {
setPosts(r.items)
setTotalPosts(r.total)
setSourceCounts(r.source_counts)
})
.catch(async (e) => {
const detail = e instanceof Error ? e.message : 'Failed to load archive'
if (!detail.includes('404')) {
setLoadErr(detail)
return
}
try {
const legacyPosts = await swrFetch('archive-legacy-500', 3 * 60_000, () =>
getPosts(500, 1),
)
const fallback = buildArchiveFallbackResponse(
legacyPosts,
archivePage,
ARCHIVE_PAGE_SIZE,
src,
)
setPosts(fallback.items)
setTotalPosts(fallback.total)
setSourceCounts(fallback.source_counts)
setLoadErr('')
} catch (legacyErr) {
setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail)
}
})
.finally(() => setLoading(false))
}, [archivePage, posts.length, src])
const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE))
const archiveSafePage = Math.min(archivePage, archiveTotalPages)
const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0)
const sourceTabs: [string, number][] = [
['all', allSourcesCount],
...sourceCounts.map((item) => [item.source, item.count] as [string, number]),
]
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Archive</h1>
{/* No count slot — the source tabs and pagination already show
per-source and total counts. */}
<PageHint>
Signals from retired scanner experiments (rsi_reversal, sma_reclaim, breakout,
test/phase1). Read-only — the bot no longer acts on any of these.
</PageHint>
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
{sourceTabs.map(([s, n]) => (
<button
key={s}
onClick={() => {
setSrc(s)
setArchivePage(1)
}}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--line)',
background: src === s ? 'var(--ink)' : 'transparent',
color: src === s ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11,
cursor: 'pointer',
fontWeight: src === s ? 600 : 400,
}}
>
{s} <span style={{ opacity: 0.6 }}>{n}</span>
</button>
))}
</div>
{loading && (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading…</div>
)}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
⚠️ {`Couldn't load archive — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button
className="btn ghost"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}
>
Retry
</button>
</div>
</div>
)}
{!loading && !loadErr && totalPosts === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No archived signals.
</div>
)}
{!loading && posts.length > 0 && (
<>
<div className="post-stream">
{posts.map((p) => (
<PostRow key={p.id} post={p} />
))}
</div>
<Pagination
page={archiveSafePage}
total={archiveTotalPages}
count={totalPosts}
pageSize={ARCHIVE_PAGE_SIZE}
onChange={setArchivePage}
/>
</>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useLocale } from 'next-intl'
import type { TrumpPost } from '@/types'
import { getPosts, getFundingSnapshot, type FundingSnapshot } from '@/lib/api'
import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
import InfoTip from '@/components/ui/InfoTip'
// MacroPanel is 631 lines with heavy indicator math — split it out.
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
ssr: false,
loading: () => (
<div
style={{ height: 280, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 20 }}
/>
),
})
/**
* System 2 — BTC bottom-reversal. Its own dedicated page.
* Shows the scanner control + ONLY source === 'btc_bottom_reversal' signals.
*/
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
type SentimentFilter = (typeof SENTIMENTS)[number]
type BtcTab = 'bottom' | 'funding'
interface MacroVibesPageProps {
initialPosts?: TrumpPost[] | null
initialFundingSnapshot?: FundingSnapshot | null
}
export default function MacroVibesPage({
initialPosts = null,
initialFundingSnapshot = null,
}: MacroVibesPageProps) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
const [loading, setLoading] = useState(initialPosts === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [tab, setTab] = useState<BtcTab>('bottom')
// Tab-scoped post source. 'bottom' = MVRV/200WMA confluence,
// 'funding' = funding-rate extreme reversal.
const tabSource = tab === 'bottom' ? 'btc_bottom_reversal' : 'funding_reversal'
useEffect(() => {
// Fetch BY SOURCE, not the latest-500 global feed. These scanner signals
// are rare (2–4/cycle, hourly) and were being pushed off the latest-500
// page by frequent Trump posts — making the Macro page look empty even
// when signals existed. Server-side source filter guarantees we get them.
setLoading(true)
swrFetch(
`posts-src-${tabSource}`,
3 * 60_000,
() => getPosts(200, 1, tabSource),
(fresh) => setPosts(fresh),
)
.then((p) => {
setPosts(p)
setLoadErr('')
})
.catch((e) =>
setLoadErr(
e instanceof Error ? e.message : isZh ? '信号加载失败' : 'Failed to load signals',
),
)
.finally(() => setLoading(false))
}, [tabSource, isZh])
const btcPosts = useMemo(
() => posts.filter((p) => (p.source || '') === tabSource),
[posts, tabSource],
)
const filtered = useMemo(
() => btcPosts.filter((p) => sentFilter === 'all' || p.sentiment === sentFilter),
[btcPosts, sentFilter],
)
const sentimentLabels: Record<SentimentFilter, string> = {
all: isZh ? '全部' : 'All',
bullish: isZh ? '看多' : 'Bullish',
bearish: isZh ? '看空' : 'Bearish',
neutral: isZh ? '中性' : 'Neutral',
}
return (
<div className="page">
<div className="page-head">
<div>
{/* No "You open · bot manages" badge — the SystemControl strip right
below says the same thing verbatim ("You open · bot manages exit"). */}
<h1 className="page-title" style={{ margin: 0 }}>
{isZh ? '宏观氛围' : 'Macro Vibes'}
</h1>
</div>
<span className="chip">
<span className="live-dot" />
Live
</span>
</div>
<SystemControl system="btc" />
{/* Tabs + inline description on the same row — avoids a separate
"section hint" block that adds a third layer before the data. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
flexWrap: 'wrap',
marginTop: 14,
marginBottom: 14,
}}
>
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', flexShrink: 0 }}>
<button
onClick={() => setTab('bottom')}
className={`nav-tab ${tab === 'bottom' ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{isZh ? '周期底部' : 'Macro Bottom'}
</button>
<button
onClick={() => setTab('funding')}
className={`nav-tab ${tab === 'funding' ? 'active' : ''}`}
style={{ border: 'none', cursor: 'pointer' }}
>
{isZh ? '资金费率反转' : 'Funding Reversal'}
</button>
</div>
<span style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.4 }}>
{tab === 'bottom' ? (
<>
<strong style={{ color: 'var(--ink-2)' }}>≥2 of 3:</strong> AHR999 &lt; 0.45 · 200w MA
· Pi Cycle Bottom — long-only, rare.
</>
) : (
<>
Fades crowded perps when 30d cumulative funding crosses <strong>±3%</strong> and
cools. Hourly.
</>
)}
</span>
</div>
{/* Macro indicator panel — only relevant on the Macro Bottom tab where
users are reasoning about the broader risk regime. The Funding tab
has its own live funding panel below. */}
{tab === 'bottom' && <MacroPanel />}
{/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the
backend scanner is disabled (services/funding_signal.py _enabled=False,
operator-only toggle), so the panel only ever showed "Paused / No
signals yet" — and breakout scanning is unrelated to funding reversal
anyway. Component kept at components/dashboard/SignalMonitor.tsx;
remount here if the scanner is ever re-enabled. */}
{tab === 'funding' && <FundingPanel isZh={isZh} initialSnapshot={initialFundingSnapshot} />}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
{SENTIMENTS.map((f) => (
<button
key={f}
onClick={() => setSentFilter(f)}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11,
cursor: 'pointer',
fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
{loading && <BtcSkeleton />}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
⚠️ {`Couldn’t load signals — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button
className="btn ghost"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}
>
{isZh ? '重试' : 'Retry'}
</button>
</div>
</div>
)}
{!loading && !loadErr && filtered.length === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
{tab === 'bottom' ? 'No bottom signals yet.' : 'No funding signals yet.'}
<div style={{ fontSize: 12, marginTop: 6 }}>
{tab === 'bottom'
? 'Intentionally rare — fires only at genuine cycle bottoms.'
: 'Check the live panel above for current funding state.'}
</div>
</div>
)}
{!loading && filtered.length > 0 && (
<div className="post-stream">
{filtered.map((p) => (
<PostRow key={p.id} post={p} />
))}
</div>
)}
</div>
)
}
// ── Funding rate live panel ─────────────────────────────────────────────────
function FundingPanel({
isZh,
initialSnapshot = null,
}: {
isZh: boolean
initialSnapshot?: FundingSnapshot | null
}) {
const [snap, setSnap] = useState<FundingSnapshot | null>(initialSnapshot)
const [err, setErr] = useState('')
useEffect(() => {
let alive = true
function load() {
swrFetch(
'funding-snapshot',
5 * 60_000, // 5 min — funding cadence is 1–8h, no point polling faster
() => getFundingSnapshot(),
(fresh) => {
if (alive) setSnap(fresh)
},
)
.then((s) => {
if (alive) {
setSnap(s)
setErr('')
}
})
.catch((e) => {
if (alive) setErr(e instanceof Error ? e.message : isZh ? '加载失败' : 'load failed')
})
}
load()
const id = setInterval(load, 5 * 60_000)
return () => {
alive = false
clearInterval(id)
}
}, [isZh])
if (err) {
return (
<div className="card" style={{ padding: 14, color: 'var(--down)', fontSize: 12 }}>
{`Funding snapshot failed — ${err}`}
</div>
)
}
if (!snap) {
return (
<div className="skeleton-card">
<div className="skeleton sk-line sk-w-3q" />
<div className="skeleton sk-title sk-w-half" />
<div className="skeleton sk-line sk-w-full" />
</div>
)
}
if (!snap.ok) {
return (
<div className="card" style={{ padding: 14, color: 'var(--ink-3)', fontSize: 12 }}>
{`Funding data unavailable — ${snap.error || 'unknown error'}`}
</div>
)
}
// Color the cumulative figure by direction + extremity.
const cum = snap.cum_30d_pct ?? 0
const thr = snap.extreme_threshold_pct ?? 3
const extreme = Math.abs(cum) >= thr
const cumColor = extreme ? (cum > 0 ? 'var(--down)' : 'var(--up)') : 'var(--ink)'
// Direction hint if extreme (longs paying = SHORT setup; shorts paying = LONG)
const directionHint = extreme
? cum > 0
? 'Longs are crowded; reversal bias is SHORT.'
: 'Shorts are crowded; reversal bias is LONG.'
: 'Funding remains inside the normal range.'
return (
<div className="card" style={{ padding: 16, marginBottom: 12 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12,
}}
>
<div style={{ fontSize: 14, fontWeight: 700 }}>BTC perp funding · live</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
{`${snap.cadence_hours}h cadence · ${snap.coverage_days}d coverage · ${
snap.venue ? snap.venue.charAt(0).toUpperCase() + snap.venue.slice(1) : 'Binance'
}`}
</div>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
gap: 12,
marginBottom: 12,
}}
>
<StatBox
label="Latest cycle"
value={snap.latest_rate_pct == null ? '—' : `${snap.latest_rate_pct.toFixed(4)}%`}
hint="The most recent funding payment, in % per cycle. Positive = longs pay shorts."
/>
<StatBox
label="Last 24h avg"
value={snap.last_24h_avg_pct == null ? '—' : `${snap.last_24h_avg_pct.toFixed(4)}%`}
hint="Average of the last 24 hours of funding cycles. Smooths out one-off prints."
/>
<StatBox
label="30d cumulative"
value={`${cum >= 0 ? '+' : ''}${cum.toFixed(2)}%`}
color={cumColor}
sub={`extreme @ ±${thr}%`}
hint="Sum of every funding payment over 30 days. Crosses ±3% = one side has been paying for weeks — overcrowded."
/>
<StatBox
label="Signal"
value={snap.signal_fired ? '🚨 FIRED' : extreme ? '⏳ Watching' : '✓ Quiet'}
color={
snap.signal_fired ? 'var(--down)' : extreme ? 'var(--amber, #f59e0b)' : 'var(--up)'
}
hint="Quiet = normal range. Watching = extreme funding detected, waiting for mean-revert + price confirm. Fired = trade triggered."
/>
</div>
<div
style={{
padding: '8px 12px',
borderRadius: 6,
fontSize: 12,
background: extreme ? 'var(--down-soft, rgba(220,38,38,.08))' : 'var(--bg-sunk)',
color: extreme ? 'var(--down)' : 'var(--ink-3)',
marginBottom: 12,
}}
>
{directionHint}
{snap.debug?.reason && (
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
({String(snap.debug.reason).replaceAll('_', ' ')})
</span>
)}
</div>
{/* 7-day sparkline removed: the y-range was forced to include the ±threshold
lines, so in normal regimes the curve rendered as a flat line on the zero
axis with ~80% of the plot being empty danger-zone shading. The stat boxes
above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
</div>
)
}
function StatBox({
label,
value,
color,
sub,
hint,
}: {
label: string
value: string
color?: string
sub?: string
hint?: string
}) {
return (
<div
style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 6,
border: '1px solid var(--line)',
// The InfoTip bubble overflows the stat box; let it escape.
overflow: 'visible',
}}
>
<div
style={{
fontSize: 10,
color: 'var(--ink-4)',
textTransform: 'uppercase',
letterSpacing: '.06em',
marginBottom: 4,
display: 'flex',
alignItems: 'center',
}}
>
{label}
{hint && <InfoTip text={hint} placement="top" width={220} />}
</div>
<div
style={{
fontSize: 16,
fontWeight: 700,
color: color || 'var(--ink)',
fontVariantNumeric: 'tabular-nums',
}}
>
{value}
</div>
{sub && <div style={{ fontSize: 10, color: 'var(--ink-4)', marginTop: 2 }}>{sub}</div>}
</div>
)
}
function BtcSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
<div className="skeleton sk-line-sm" style={{ width: 60 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line sk-w-half" />
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,58 @@
'use client'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { usePathname } from '@/lib/navigation'
import TelegramCard from '@/components/telegram/TelegramCard'
// BotConfigPanel is heavy and only needed after wallet connect — lazy load it.
const BotConfigPanel = dynamic(() => import('@/components/trades/BotConfigPanel'), {
ssr: false,
loading: () => (
<div
style={{ height: 200, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 16 }}
/>
),
})
export default function SettingsClient() {
const pathname = usePathname()
const locale = pathname.split('/')[1] || 'en'
const href = (path: string) => `/${locale}${path}`
return (
<>
<div id="bot-config">
<BotConfigPanel />
</div>
<div style={{ marginTop: 8 }}>
<TelegramCard />
</div>
<div className="card" style={{ padding: '14px 20px', marginTop: 8 }}>
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
<Link
href={href('/privacy')}
style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}
>
Privacy Policy →
</Link>
<Link
href={href('/terms')}
style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}
>
Terms →
</Link>
<Link
href={href('/contact')}
style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}
>
Contact →
</Link>
</div>
</div>
</>
)
}
@@ -0,0 +1,289 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { usePathname } from '@/lib/navigation'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import type { BotTrade } from '@/types'
import { getTrades, getUserPublic } from '@/lib/api'
import { useDashboardStore } from '@/store/dashboard'
import {
getCachedViewEnvelope,
getOrCreateViewEnvelope,
type SignedEnvelope,
} from '@/lib/signedRequest'
import TradeTable from '@/components/trades/TradeTable'
import OpenPositions from '@/components/positions/OpenPositions'
import PageHint from '@/components/ui/PageHint'
export default function TradesPageClient() {
const intlLocale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const { address, isConnected } = useAccount()
const { signMessageAsync } = useSignMessage()
const {
isSubscribed,
hlApiKeySet,
paperMode,
setSubscribed,
setHlApiKeySet,
setPaperMode,
setBotReadiness,
} = useDashboardStore()
const pathname = usePathname()
const locale = pathname.split('/')[1] || 'en'
const [mounted, setMounted] = useState(false)
const [trades, setTrades] = useState<BotTrade[]>([])
const [loading, setLoading] = useState(true)
const [loadErr, setLoadErr] = useState('')
const [needsUnlock, setNeedsUnlock] = useState(false)
const [unlocking, setUnlocking] = useState(false)
const aliveRef = useRef(true)
useEffect(() => {
setMounted(true)
aliveRef.current = true
return () => {
aliveRef.current = false
}
}, [])
// genRef guards loadAll against stale-closure wallet-switch races.
// `snapAddr !== address` inside an async function compares two copies of
// the same closure-captured value — it's always equal even when the wallet
// has changed, so the check is a no-op. genRef is a mutable ref that every
// closure can read, so `gen !== genRef.current` correctly detects staleness.
const genRef = useRef(0)
useEffect(() => {
genRef.current++
}, [address])
// B41: TradesPageClient can be the entry point (direct navigation to /trades)
// without DashboardClient or BotConfigPanel ever running. In that case the
// Zustand store has isSubscribed=false (initial default), causing the
// "Bot not configured" banner to appear for valid subscribers.
// Fix: fetch /user/{wallet}/public here too — it's lightweight, unauthenticated,
// and idempotent with the same fetch DashboardClient does.
useEffect(() => {
if (!address || !isConnected) return
let cancelled = false
const snap = address
getUserPublic(snap.toLowerCase())
.then((u) => {
if (cancelled || snap !== address) return
setSubscribed(u.active)
setHlApiKeySet(u.hl_api_key_set)
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
setPaperMode(!!u.paper_mode)
})
.catch(() => {})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// Load public posts always; load private trades only if we have a view
// envelope. `forcedEnv` lets the in-page Unlock button pass a freshly-minted
// one so the user never has to detour through the Settings page first.
async function loadAll(forcedEnv?: SignedEnvelope) {
if (!address || !isConnected) {
setTrades([])
setLoading(false)
setLoadErr('')
setNeedsUnlock(false)
return
}
const gen = genRef.current
const snapAddr = address // for API calls only — NOT for stale detection
setLoading(true)
const env =
forcedEnv ??
getCachedViewEnvelope('view_trades', snapAddr) ??
getCachedViewEnvelope('view_user', snapAddr)
let failed = false
try {
const t = env
? await getTrades(snapAddr, env, 500, 1).catch((e) => {
failed = true
setLoadErr(e instanceof Error ? e.message : 'Failed to load trades')
return [] as BotTrade[]
})
: []
// Use genRef — NOT `snapAddr !== address` (stale closure: both are the
// same closed-over value and the comparison is always false).
if (!aliveRef.current || gen !== genRef.current) return
setTrades(t)
setNeedsUnlock(!env)
if (env && !failed) setLoadErr('')
} finally {
if (aliveRef.current && gen === genRef.current) setLoading(false)
}
}
useEffect(() => {
// B27/B35: clear stale previous-wallet data BEFORE the async fetch so the
// UI never briefly shows another wallet's private trades.
setTrades([])
setNeedsUnlock(false)
setLoadErr('')
// On navigation we only use a cached envelope — never auto-popup the
// wallet. If none is cached the user unlocks explicitly via the button.
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isConnected])
// Mint a view envelope right here (one signature) and reload — no Settings detour.
async function handleUnlock() {
if (!address) return
setUnlocking(true)
setLoadErr('')
try {
// view_user is a superset accepted by /trades, /positions/open,
// /positions/today — one signature unlocks everything on this page.
// Previously view_trades was used here, but that left OpenPositions
// locked (it requires view_positions or view_user).
const env = await getOrCreateViewEnvelope({
action: 'view_user',
wallet: address,
signMessageAsync,
})
await loadAll(env)
} catch (e) {
// User rejected the signature or it failed — keep the Unlock CTA visible.
const msg = e instanceof Error ? e.message : 'Signature cancelled'
if (aliveRef.current) setLoadErr(/reject|denied|cancel/i.test(msg) ? '' : msg)
} finally {
if (aliveRef.current) setUnlocking(false)
}
}
// B40: paper users have no HL key but the bot IS configured for them — don't
// show "Bot not configured" just because hlApiKeySet is false.
// B41: on cold start (navigating directly to /trades), isSubscribed starts
// false in the store until DashboardClient or BotConfigPanel fetches
// /user/public. Guard with !loading so the banner only appears once
// the page has confirmed the wallet is genuinely unconfigured.
const needsSetup =
mounted && isConnected && !loading && (!isSubscribed || (!hlApiKeySet && !paperMode))
return (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
{/* Says what the page is, not where things sit — the section cards
below are self-labelled ("Open positions", KPI row, table). */}
<PageHint>What the bot holds right now, and every trade it has closed.</PageHint>
</div>
</div>
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
dashboard. Show one connect prompt instead and skip the data UI. */}
{mounted && !isConnected ? (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>
Connect your wallet to see your trades
</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
Positions and trade history are wallet-bound and private. Use the Connect wallet button
in the top-right corner.
</div>
</div>
) : (
<>
{needsSetup && (
<div
className="card"
style={{
padding: '14px 18px',
marginBottom: 16,
background: 'var(--amber-soft)',
borderColor: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12,
}}
>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{isZh ? '机器人尚未配置完成' : 'Bot not configured'}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{isZh
? '在机器人开始交易前,请先去设置页订阅并绑定 Hyperliquid API。'
: 'Subscribe and link your Hyperliquid API wallet on the Settings page before the bot can trade.'}
</div>
</div>
<Link
href={`/${locale}/settings`}
className="btn amber"
style={{ padding: '8px 16px', fontSize: 13, textDecoration: 'none' }}
>
{isZh ? '前往设置 →' : 'Go to Settings →'}
</Link>
</div>
)}
<OpenPositions />
{mounted && isConnected && !loading && needsUnlock && (
<div
className="card"
style={{
padding: 16,
margin: '12px 0',
textAlign: 'center',
fontSize: 13,
background: 'var(--amber-soft)',
borderColor: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
}}
>
<div style={{ marginBottom: 10, color: 'var(--ink-2)' }}>
🔒 Your trade history is private. Sign once to unlock it on this device (valid for a
few minutes, no transaction, no gas).
</div>
<button
className="btn amber"
style={{ fontSize: 13, padding: '8px 16px' }}
disabled={unlocking}
onClick={handleUnlock}
>
{unlocking ? 'Waiting for signature…' : 'Unlock trade history'}
</button>
</div>
)}
{!loading && loadErr && (
<div
className="card"
style={{
padding: 16,
margin: '12px 0',
textAlign: 'center',
color: 'var(--down)',
fontSize: 13,
}}
>
⚠️{' '}
{isZh ? `无法加载交易历史:${loadErr}` : `Couldn’t load trade history — ${loadErr}`}
<button
className="btn ghost"
style={{ fontSize: 12, padding: '5px 12px', marginLeft: 10 }}
onClick={() => location.reload()}
>
{isZh ? '重试' : 'Retry'}
</button>
</div>
)}
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
</>
)}
</div>
)
}
+371
View File
@@ -0,0 +1,371 @@
'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import type { TrumpPost } from '@/types'
import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api'
import { hasCached, swrFetch } from '@/lib/cache'
import PostRow, { isAiScored } from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
import Pagination from '@/components/ui/Pagination'
const PAGE_SIZE = 30
const SENTIMENTS = ['all', 'bullish', 'bearish', 'neutral'] as const
type SentimentFilter = (typeof SENTIMENTS)[number]
type SignalFilter = 'all' | 'actionable' | 'buy' | 'short'
interface TrumpSignalPageProps {
initialData?: PostListResponse | null
}
const EMPTY_COUNTS: PostListResponse['counts'] = {
all: 0,
actionable: 0,
buy: 0,
short: 0,
off_topic: 0,
}
function matchesBaseFilters(post: TrumpPost, sentFilter: SentimentFilter, hideNoise: boolean) {
if (hideNoise && !isAiScored(post)) return false
if (sentFilter !== 'all' && post.sentiment !== sentFilter) return false
return true
}
function matchesSignalFilter(post: TrumpPost, sigFilter: SignalFilter) {
if (sigFilter === 'actionable') return post.signal === 'buy' || post.signal === 'short'
if (sigFilter === 'buy') return post.signal === 'buy'
if (sigFilter === 'short') return post.signal === 'short'
return true
}
function buildLocalCounts(
posts: TrumpPost[],
sentFilter: SentimentFilter,
hideNoise: boolean,
): PostListResponse['counts'] {
const sentimentScoped = posts.filter((p) => sentFilter === 'all' || p.sentiment === sentFilter)
const base = sentimentScoped.filter((p) => matchesBaseFilters(p, sentFilter, hideNoise))
return {
all: base.length,
actionable: base.filter((p) => p.signal === 'buy' || p.signal === 'short').length,
buy: base.filter((p) => p.signal === 'buy').length,
short: base.filter((p) => p.signal === 'short').length,
off_topic: sentimentScoped.filter((p) => !isAiScored(p)).length,
}
}
export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageProps) {
const [posts, setPosts] = useState<TrumpPost[]>(initialData?.items ?? [])
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
const [counts, setCounts] = useState<PostListResponse['counts']>(
initialData?.counts ?? EMPTY_COUNTS,
)
const [loading, setLoading] = useState(initialData === null)
const [loadErr, setLoadErr] = useState('')
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
const [sigFilter, setSigFilter] = useState<SignalFilter>('all')
const [hideNoise, setHideNoise] = useState(false)
const [page, setPage] = useState(1)
const [serverPaging, setServerPaging] = useState(true)
// Tracks whether we already have any rows on screen, WITHOUT putting
// posts.length in the effect deps (which would re-run the effect on every
// setPosts and fire a redundant fetch). Updated after each load below.
const hasRowsRef = useRef((initialData?.items?.length ?? 0) > 0)
useEffect(() => {
const filters = {
// Don't apply the sentiment bias when a directional signal filter is
// active — Buy/Short/Actionable already imply direction, and combining
// them with a sentiment filter produces confusing empty results.
// sentFilter is intentionally NOT reset when the user switches tabs so
// their preference is preserved if they switch back to 'all'.
sentiment: sentFilter === 'all' || sigFilter !== 'all' ? undefined : sentFilter,
signal: sigFilter === 'all' ? undefined : sigFilter,
aiScoredOnly: hideNoise,
} as const
// sentFilter only participates in the key when sigFilter is 'all' — it's
// ignored by the query otherwise, so caching it in the key would create
// phantom cache entries that never match on the way back.
const effectiveSent = sigFilter === 'all' ? sentFilter : 'all'
const key = `posts-truth-page-${page}-sent-${effectiveSent}-sig-${sigFilter}-noise-${hideNoise ? '1' : '0'}`
const legacyKey = 'posts-truth-legacy-500'
setLoadErr('')
setLoading(!hasRowsRef.current && !hasCached(key) && !hasCached(legacyKey))
swrFetch(
key,
3 * 60_000,
() => getPostsPage(PAGE_SIZE, page, 'truth', filters),
(fresh) => {
setPosts(fresh.items)
setTotalPosts(fresh.total)
setCounts(fresh.counts)
hasRowsRef.current = fresh.items.length > 0
},
)
.then((r) => {
setPosts(r.items)
setTotalPosts(r.total)
setCounts(r.counts)
setServerPaging(true)
setLoadErr('')
hasRowsRef.current = r.items.length > 0
})
.catch(async (e) => {
const detail = e instanceof Error ? e.message : 'Failed to load posts'
if (!detail.includes('404')) {
setLoadErr(detail)
return
}
try {
// Legacy fallback (old backend without /posts-paged). Pull the
// largest window the /posts endpoint allows (le=500) so client-side
// pagination below has the full set to slice — 200 silently dropped
// older matching posts once the truth feed grew past one page.
const legacyPosts = await swrFetch(legacyKey, 3 * 60_000, () => getPosts(500, 1, 'truth'))
const localCounts = buildLocalCounts(legacyPosts, sentFilter, hideNoise)
setPosts(legacyPosts)
setTotalPosts(localCounts.all)
setCounts(localCounts)
setServerPaging(false)
setLoadErr('')
hasRowsRef.current = legacyPosts.length > 0
} catch (legacyErr) {
setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail)
}
})
.finally(() => setLoading(false))
}, [page, sentFilter, sigFilter, hideNoise])
const filtered = useMemo(
() =>
serverPaging
? posts
: posts.filter(
(p) =>
matchesBaseFilters(p, sentFilter, hideNoise) && matchesSignalFilter(p, sigFilter),
),
[hideNoise, posts, sentFilter, serverPaging, sigFilter],
)
const noiseCount = counts.off_topic
const totalPages = Math.max(1, Math.ceil(totalPosts / PAGE_SIZE))
const safePage = Math.min(page, totalPages)
const pageItems = serverPaging
? posts
: filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
const signalLabels: Record<SignalFilter, string> = {
all: 'All',
actionable: 'Actionable',
buy: 'Buy',
short: 'Short',
}
const sentimentLabels: Record<SentimentFilter, string> = {
all: 'All',
bullish: 'Bullish',
bearish: 'Bearish',
neutral: 'Neutral',
}
return (
<div className="page">
<div className="page-head">
<div>
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
always shows the Auto-Trade feature + its live state. */}
<h1 className="page-title" style={{ margin: 0 }}>
Trump Signal
</h1>
{/* No count slot — the filter tabs right below already show the
per-filter numbers (All / Actionable / Buy / Short). */}
<PageHint>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the
threshold.
</PageHint>
</div>
<span className="chip">
<span className="live-dot" />
Live
</span>
</div>
<SystemControl system="trump" />
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
margin: '16px 0 12px',
flexWrap: 'wrap',
}}
>
{/* Signal filter tabs */}
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
{(
[
{ key: 'all', hint: 'Every post the scraper has captured.' },
{ key: 'actionable', hint: 'Only posts the AI flagged as BUY or SHORT.' },
{ key: 'buy', hint: 'Posts the AI scored as a long signal.' },
{ key: 'short', hint: 'Posts the AI scored as a short signal.' },
] as const
).map((f) => (
<button
key={f.key}
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
onClick={() => {
setSigFilter(f.key)
setPage(1)
// sentFilter is intentionally NOT reset here — the user's
// bias preference is preserved so it's still active when
// they switch back to 'all'. The query layer ignores
// sentFilter whenever sigFilter is directional.
}}
>
{f.key === 'actionable' ? '🔥 ' : ''}
{signalLabels[f.key]} <span style={{ color: 'var(--ink-4)' }}>{counts[f.key]}</span>
</button>
))}
</div>
{/* Sentiment + noise filters — hidden when a directional filter is
already active (actionable / buy / short), because:
- "buy" already implies bullish direction
- "short" already implies bearish direction
- "actionable" = buy ∪ short — sentiment adds no further info
Showing them would create confusing combinations (e.g. Buy + Bearish
returns 0 results with no explanation). */}
{sigFilter === 'all' && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
{/* One-click collapse of off-topic (non-crypto, un-scored) posts.
Hidden when a sentiment filter is active — Bullish/Bearish/Neutral
results are all AI-scored by definition, so there are no off-topic
posts to hide and the button would be meaningless. */}
{(noiseCount > 0 || hideNoise) && sentFilter === 'all' && (
<button
onClick={() => {
setHideNoise((v) => !v)
setPage(1)
}}
title={`Show only crypto-relevant posts — hides ${noiseCount} off-topic Trump posts`}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--line)',
background: hideNoise ? 'var(--ink)' : 'transparent',
color: hideNoise ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11,
cursor: 'pointer',
fontWeight: 600,
marginRight: 4,
}}
>
{hideNoise ? 'Show all' : 'Signals only'}
</button>
)}
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
Bias
<InfoTip
text="Filters by the post's directional bias (bullish / bearish / neutral). Bias ≠ trade signal — a bearish post can still be a BUY if the market reads it as priced-in."
placement="left"
/>
</span>
{SENTIMENTS.map((f) => (
<button
key={f}
onClick={() => {
setSentFilter(f)
setPage(1)
}}
style={{
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--line)',
background: sentFilter === f ? 'var(--ink)' : 'transparent',
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11,
cursor: 'pointer',
fontWeight: sentFilter === f ? 600 : 400,
}}
>
{sentimentLabels[f]}
</button>
))}
</div>
)}
</div>
{loading && <TrumpSkeleton />}
{!loading && loadErr && (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
⚠️ {`Couldn't load signals — ${loadErr}`}
<div style={{ marginTop: 10 }}>
<button
className="btn ghost"
style={{ fontSize: 12, padding: '6px 14px' }}
onClick={() => location.reload()}
>
Retry
</button>
</div>
</div>
)}
{!loading && !loadErr && totalPosts === 0 && (
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
No Trump signals match the current filter.
</div>
)}
{!loading && pageItems.length > 0 && (
<>
<div className="post-stream">
{pageItems.map((p) => (
<PostRow key={p.id} post={p} />
))}
</div>
<Pagination
page={safePage}
total={totalPages}
count={totalPosts}
pageSize={PAGE_SIZE}
onChange={setPage}
/>
</>
)}
</div>
)
}
function TrumpSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton-card" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<div className="skeleton sk-line sk-w-q" />
<div className="skeleton sk-line" style={{ width: 60 }} />
</div>
<div className="skeleton sk-title sk-w-3q" />
<div className="skeleton sk-line sk-w-full" />
<div className="skeleton sk-line sk-w-half" />
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<div className="skeleton sk-line-sm" style={{ width: 56 }} />
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
<div className="skeleton sk-line-sm" style={{ width: 64 }} />
</div>
</div>
))}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More