diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47d377e --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 37c4e58..5675979 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ *.tsbuildinfo *.log .DS_Store +*.dump +*.sqlite +*.db diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0965e7e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/README.md b/README.md index d003a89..bde274b 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..4fb7dce --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..e292e3b --- /dev/null +++ b/apps/api/src/app.ts @@ -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 +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..e6d1a25 --- /dev/null +++ b/apps/api/src/main.ts @@ -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) }) diff --git a/apps/api/src/routes/accounts.ts b/apps/api/src/routes/accounts.ts new file mode 100644 index 0000000..2676a17 --- /dev/null +++ b/apps/api/src/routes/accounts.ts @@ -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') + }) +} diff --git a/apps/api/src/routes/dev.ts b/apps/api/src/routes/dev.ts new file mode 100644 index 0000000..f1f7085 --- /dev/null +++ b/apps/api/src/routes/dev.ts @@ -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('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', + } + }) +} diff --git a/apps/api/src/routes/kol.ts b/apps/api/src/routes/kol.ts new file mode 100644 index 0000000..3aad26e --- /dev/null +++ b/apps/api/src/routes/kol.ts @@ -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 } + }) +} diff --git a/apps/api/src/routes/macro.ts b/apps/api/src/routes/macro.ts new file mode 100644 index 0000000..1903cf2 --- /dev/null +++ b/apps/api/src/routes/macro.ts @@ -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['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) } + }, + ) +} diff --git a/apps/api/src/routes/positions.ts b/apps/api/src/routes/positions.ts new file mode 100644 index 0000000..ec2f142 --- /dev/null +++ b/apps/api/src/routes/positions.ts @@ -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', + } + }) +} diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 0000000..c2dd588 --- /dev/null +++ b/apps/api/src/routes/posts.ts @@ -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['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`sum(case when ${posts.signal} in ('buy','short') then 1 else 0 end)`, + buy: sql`sum(case when ${posts.signal} = 'buy' then 1 else 0 end)`, + short: sql`sum(case when ${posts.signal} = 'short' then 1 else 0 end)`, + off_topic: sql`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 & { 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) => ({ + ...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, + } + }) +} diff --git a/apps/api/src/routes/signals.ts b/apps/api/src/routes/signals.ts new file mode 100644 index 0000000..a8f889a --- /dev/null +++ b/apps/api/src/routes/signals.ts @@ -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('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('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) + }, + ) +} diff --git a/apps/api/src/routes/telegram.ts b/apps/api/src/routes/telegram.ts new file mode 100644 index 0000000..0d62490 --- /dev/null +++ b/apps/api/src/routes/telegram.ts @@ -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, + ) + 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) + }) +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..564a599 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..06e579c --- /dev/null +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..fd47bdf --- /dev/null +++ b/apps/web/components.json @@ -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" + } +} diff --git a/apps/web/components/PageHead.tsx b/apps/web/components/PageHead.tsx new file mode 100644 index 0000000..f18527c --- /dev/null +++ b/apps/web/components/PageHead.tsx @@ -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 ( + + {title} + + + {keywords && ( + + )} + + + + + {canonical && } + {languages && + Object.entries(languages).map(([language, value]) => ( + + ))} + + + + + + + {canonical && } + + + + + + + + ) +} diff --git a/apps/web/components/Providers.tsx b/apps/web/components/Providers.tsx new file mode 100644 index 0000000..456c8de --- /dev/null +++ b/apps/web/components/Providers.tsx @@ -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 ( + + + + {/* Single shared WebSocket — all components subscribe via useWsSubscribe */} + {children} + + + + ) +} diff --git a/apps/web/components/btc/MacroPanel.tsx b/apps/web/components/btc/MacroPanel.tsx new file mode 100644 index 0000000..b0e5756 --- /dev/null +++ b/apps/web/components/btc/MacroPanel.tsx @@ -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 = { + 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 ( + + + Open chart + {children} + + + + ) +} + +function ThresholdChip({ + tone = 'neutral', + active = false, + children, +}: { + tone?: Tone + active?: boolean + children: ReactNode +}) { + const cls = `macro-threshold-chip ${tone}${active ? ' active' : ''}` + return {children} +} + +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 = ( +
+
+ {String(rank).padStart(2, '0')} + {label} + +
+
{value}
+
+ ) + const chips = ( +
+ {thresholds.map((item, i) => ( + + {item.label} + + ))} +
+ ) + const action = ( +
+ {chartLabel} +
+ ) + + if (hero) { + return ( +
+ {head} +
+
{summary}
+ {chips} +
+ {action} +
+ ) + } + + return ( +
+ {head} +
{summary}
+ {chips} + {action} +
+ ) +} + +function GuideCard({ + title, + summary, + thresholds, + chartHref, + chartLabel = 'CoinGlass', +}: { + title: string + summary: string + thresholds: Array<{ label: string; tone?: Tone }> + chartHref: string + chartLabel?: string +}) { + return ( +
+
{title}
+
{summary}
+
+ {thresholds.map((item) => ( + + {item.label} + + ))} +
+
+ {chartLabel} +
+
+ ) +} + +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 ( +
+
{title}
+ {children} +
+ ) +} + +export default function MacroPanel() { + const [snap, setSnap] = useState(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 ( +
+ Couldn't load macro indicators — {err} +
+ ) + } + if (!snap) { + return ( +
+
+
+
+
+ ) + } + if (!snap.ok || !snap.indicators) { + return ( +
+ Macro snapshot not available yet — {snap.error ?? 'cron has not run'}. +
+ ) + } + + 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 ( +
+
+
+
+ + Macro indicators +
+
+ Daily snapshot for BTC macro context. Designed for scanning first, details second. +
+
+
+
{snap.date}
+
free public sources
+
+
+ +
+
+ 1.2 expensive', tone: 'down' }, + ]} + chartHref="https://www.coinglass.com/en/pro/i/ahr999" + chartLabel="CoinGlass" + /> +
+
+ +
+
+ + +
+
+ +
+
+ + 65% safety trade', tone: 'down' }, + ]} + chartHref="https://www.coinglass.com/pro/i/bitcoin-dominance" + chartLabel="CoinGlass" + /> + 0.04 ETH leadership', tone: 'up' }, + ]} + chartHref="https://www.coinglass.com/currencies/ETHBTC" + chartLabel="CoinGlass" + /> +
+
+ +
+
+ 75 → SELL (extreme greed)', tone: 'down' }, + ]} + chartHref="https://www.coinglass.com/pro/i/FearGreedIndex?s=09" + chartLabel="CoinGlass" + /> + + +$200M strong bid', tone: 'up' }, + { label: '±$50M noise band', tone: 'neutral' }, + { label: '< −$200M heavy outflow', tone: 'down' }, + ]} + chartHref="https://www.coinglass.com/etf" + /> + +
+
+ +
+
+
+
Composite read
+
+ Advisory blend of the metrics above. Useful for context, not for blind execution. +
+
+
+ + {snap.composite_score == null + ? '—' + : (snap.composite_score > 0 ? '+' : '') + snap.composite_score.toFixed(1)} + + {snap.regime_label ? ( + + {snap.regime_label} + + ) : null} +
+
+ +
+ {snap.composite_score != null ? ( +
+ ) : null} +
+
+ {/* .word spans are hidden on screens ≤420px so the scale becomes + just "-100", "0", "+100" — fits comfortably on any phone. */} + + −100 bear + + + 0 neutral + + + +100 bull + +
+
+
+ ) +} diff --git a/apps/web/components/dashboard/ChartPanel.tsx b/apps/web/components/dashboard/ChartPanel.tsx new file mode 100644 index 0000000..75c548c --- /dev/null +++ b/apps/web/components/dashboard/ChartPanel.tsx @@ -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(null) + const chartRef = useRef(null) + const seriesRef = useRef(null) + const macroPriceLineRef = useRef(null) + const macroVerticalRef = useRef(null) + const macroLabelRef = useRef(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(null) + const macroLabelTextRef = useRef('') + // 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 = { + '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 = { + '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() + 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 ( +
+ ) +} diff --git a/apps/web/components/dashboard/PostCards.tsx b/apps/web/components/dashboard/PostCards.tsx new file mode 100644 index 0000000..9331491 --- /dev/null +++ b/apps/web/components/dashboard/PostCards.tsx @@ -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): 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 … + return ( + + {timeAgo(iso)} + {suffix} + + ) +} + +/** + * 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 … + return {new Date(iso).toLocaleString('en-US', opts)} +} + +// 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 ( +
+ {d.glyph} +
+ ) + } + // Unknown source — show first letter so user can still tell it apart. + return ( +
+ {source.charAt(0).toUpperCase()} +
+ ) +} + +function SignalPill({ signal }: { signal: string | null }) { + if (!signal || signal === 'hold') return HOLD + if (signal === 'buy') return BUY + if (signal === 'short') return SHORT + if (signal === 'sell') return SELL + return {signal.toUpperCase()} +} + +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 ( +
+ {/* ── main row ── */} +
+ +
+
+ {/* Author label depends on source — non-Trump signals come from + technical scanners or external modules, not @realDonaldTrump. */} + + {SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'} + + · + + · + {aiScored ? ( + + {post.sentiment} + + ) : ( + + off-topic · not crypto + + )} +
+

+ {expanded + ? post.text + : post.text.slice(0, aiScored ? 180 : 90) + + (post.text.length > (aiScored ? 180 : 90) ? '…' : '')} +

+
+ {/* 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 && ( +
+ + {/* Target asset chip for actionable signals */} + {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && ( + + {post.target_asset} + {post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''} + + )} +
+ {impact ? ( + <> + 1h move + = 0 ? 'up' : 'down'}`} + > + {impact.m1h == null ? '…' : fmtPct(impact.m1h)} + + + ) : ( + no data + )} +
+
+ AI + + {post.ai_confidence}% + +
+
+ )} +
+ + {/* ── expanded detail ── */} + {expanded && ( +
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 ? ( +
+
+ AI confidence + {post.ai_confidence}% +
+
+
+
+
+ ) : ( +
+ Not crypto-relevant — skipped AI scoring. +
+ )} + + {/* Trade routing — target asset + expected move */} + {post.target_asset && (post.signal === 'buy' || post.signal === 'short') && ( +
+ Trade + + {post.signal === 'buy' + ? `↑ LONG ${post.target_asset}` + : `↓ SHORT ${post.target_asset}`} + + {post.expected_move_pct != null && ( + + {`model projection ~${post.expected_move_pct}% · 1h`} + + )} + {post.category && ( + + {post.category.replace(/_/g, ' ')} + + )} +
+ )} + + {/* AI reasoning */} + {post.ai_reasoning && ( +
+
+ {post.signal === 'buy' || post.signal === 'short' + ? 'Why this signal fired' + : 'Why this was filtered out'} +
+
{post.ai_reasoning}
+
+ )} + + {/* Price impact — peak move in signal direction per window */} + {impact && ( +
+
{`Price moved · ${impact.asset} · after signal`}
+
+ {(['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 ( +
+
+ {label} +
+
= 0 ? 'up' : 'down'}`} + style={{ + fontSize: 14, + fontWeight: 600, + color: pending ? 'var(--ink-3)' : undefined, + }} + > + {pending ? '…' : fmtImpactPct(v)} +
+ {!pending && correct != null && ( +
+ {correct ? '✓' : '✗'} +
+ )} + {pending && ( +
live
+ )} +
+ ) + })} +
+
+ )} +
+ )} +
+ ) +}) + +export default PostRow +export { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } diff --git a/apps/web/components/dashboard/SignalMonitor.tsx b/apps/web/components/dashboard/SignalMonitor.tsx new file mode 100644 index 0000000..d7f61ad --- /dev/null +++ b/apps/web/components/dashboard/SignalMonitor.tsx @@ -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([]) + const [btcTrend, setBtcTrend] = useState(null) + const [lastScan, setLastScan] = useState(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 ( +
+ {/* Header */} +
+
+
+ {isZh ? '突破监控' : 'Breakout Monitor'} +
+
+ {isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'} +
+
+ {/* Enabled status dot — display-only; toggle is operator-only (X-Ingest-Key) */} +
+
+ {enabled ? (isZh ? '监控中' : 'Active') : isZh ? '已暂停' : 'Paused'} +
+
+ + {/* Status row: BTC trend + last scan */} +
+ + BTC   + {btcTrend ? ( + {btcTrend} + ) : ( + — + )} + + + {lastScan + ? isZh + ? `最近信号 ${timeAgo(lastScan.toISOString())}` + : `Last signal ${timeAgo(lastScan.toISOString())}` + : enabled + ? isZh + ? '扫描中…' + : 'Scanning…' + : isZh + ? '已暂停' + : 'Paused'} + +
+ + {/* Signal list */} + {signals.length === 0 ? ( +
+ {enabled + ? isZh + ? '· 正在等待信号…' + : '· Watching for signals…' + : isZh + ? '· 暂无历史信号' + : '· No signals yet'} +
+ ) : ( +
+ {signals.map((s) => ( +
+ {/* Top row */} +
+
+ + {symbolLabel(s.symbol)} + + {!s.enabled && ( + + silent + + )} +
+ {timeAgo(s.time)} +
+ + {/* Metrics grid */} +
+ {[ + { + 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) => ( +
+
+ {m.label} +
+
+ {m.value} +
+
+ ))} +
+ + {/* Footer */} +
+ {isZh ? `BB squeeze 处于第 ${s.bb_pct} 分位` : `BB squeeze ${s.bb_pct}th pct`}{' '} +  ·  {s.btc_trend} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/apps/web/components/nav/LanguageSwitch.tsx b/apps/web/components/nav/LanguageSwitch.tsx new file mode 100644 index 0000000..6f1c992 --- /dev/null +++ b/apps/web/components/nav/LanguageSwitch.tsx @@ -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(() => { + const seg = pathname.split('/').filter(Boolean)[0] + return locales.includes(seg as Locale) ? (seg as Locale) : defaultLocale + }, [pathname]) + + return ( +
+ {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 ( + + {label} + + ) + })} +
+ ) +} diff --git a/apps/web/components/nav/Navbar.tsx b/apps/web/components/nav/Navbar.tsx new file mode 100644 index 0000000..ef6f692 --- /dev/null +++ b/apps/web/components/nav/Navbar.tsx @@ -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 α +} + +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 ( + + ) +} + +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 ( + <> + setMobileSheetOpen(false)} /> + + + ) +} diff --git a/apps/web/components/nav/Ticker.tsx b/apps/web/components/nav/Ticker.tsx new file mode 100644 index 0000000..f6e9777 --- /dev/null +++ b/apps/web/components/nav/Ticker.tsx @@ -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>({}) + // Per-asset flash-clear timers so a fast stream doesn't leak setTimeouts. + const timers = useRef>>({}) + + 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 ( + + {a} + {c?.price != null ? fmtPrice(c.price) : '—'} + + {c?.dir === 'up' ? '▲' : c?.dir === 'down' ? '▼' : ''} + + + ) + }) + + return ( +
+
+ {strip} + {/* duplicate for seamless loop */} + + {strip} + +
+
+ ) +} diff --git a/apps/web/components/positions/OpenPositions.tsx b/apps/web/components/positions/OpenPositions.tsx new file mode 100644 index 0000000..67708de --- /dev/null +++ b/apps/web/components/positions/OpenPositions.tsx @@ -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 ( +
+ {/* Asset + paper tag */} +
+ + {p.asset} + {p.is_paper && ( + + P + + )} +
+ {/* Side */} + {p.side === 'long' ? '↗ LONG' : '↘ SHORT'} + {/* Entry → Current */} +
+
entry / current price
+
+ {fmtMoney(p.entry_price)} + → + {p.current_price != null ? ( + fmtMoney(p.current_price) + ) : ( + n/a + )} +
+
+ {/* Size + leverage (size = OPEN notional after de-risk) */} +
+
open size · lev
+
+ {p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'} + {p.leverage != null && · {p.leverage}×} +
+ {((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && ( +
+ {(p.addon_steps ?? 0) > 0 && ( + {`↑ scaled in ×${p.addon_steps} `} + )} + {(p.derisk_steps ?? 0) > 0 && ( + {`↓ trimmed ×${p.derisk_steps}`} + )} +
+ )} + {/* Per-trade Grow (pyramiding) switch */} + +
+ {/* Hold time */} +
+ {fmtHold(p.hold_minutes)} +
+ {/* Unrealized PnL */} +
+
+ {fmtMoney(p.unrealized_usd, { sign: true })} +
+
+ {fmtPct(p.unrealized_pct)} +
+ {p.realized_usd != null && p.realized_usd !== 0 && ( +
+ banked {fmtMoney(p.realized_usd, { sign: true })} from partial close +
+ )} +
+ {/* Emergency close — bypasses TP/SL/schedule. Always available. */} + +
+ ) +} + +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(null) + const [today, setToday] = useState(null) + const [err, setErr] = useState(null) + const [needsUnlock, setNeedsUnlock] = useState(false) + const [unlocking, setUnlocking] = useState(false) + // Close-confirmation modal state + const [closing, setClosing] = useState(null) + const [closeState, setCloseState] = useState<'idle' | 'signing' | 'closing' | 'err'>('idle') + + useEffect(() => { + setMounted(true) + }, []) + const [closeErr, setCloseErr] = useState('') + const [growId, setGrowId] = useState(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 ( +
+
+ Open positions +
+
+ Sign in to see open positions +
+
+ Sign once on this device to unlock your positions (valid for a few minutes, no + transaction, no gas). +
+
+ + + or go to Settings → + +
+ {err &&
⚠️ {err}
} +
+ ) + } + // 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 ( +
+ ⚠️ Could not load positions — {err} +
+ ) + } + 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 ( +
+ {/* Header strip — at-a-glance status */} +
+
+
+
+ Open +
+
+ {today?.open_count ?? 0} + + {`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`} + +
+
+ +
+ +
+
+ Unrealized +
+
0 + ? 'var(--up)' + : totalUnrealized < 0 + ? 'var(--down)' + : 'var(--ink-2)', + fontVariantNumeric: 'tabular-nums', + }} + > + {fmtMoney(totalUnrealized, { sign: true })} +
+
+ +
+ +
+
+ Today's realized +
+
+ {fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })} +
+
+ {today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'} +
+ {today != null && (today.open_realized_usd ?? 0) !== 0 && ( +
+ {`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`} +
+ )} +
+
+ + {err && ● {err}} + {growErr && !err && ● {growErr}} +
+ + {/* Position list (or empty state) */} + {positions && positions.length > 0 ? ( +
+
+ Asset + Side + Prices + Size + Hold + Unrealized + Action +
+ {positions.map((p) => ( + + ))} +
+ ) : ( +
+ No open positions. When the bot opens a trade, it will show up here. +
+ )} + + {/* ── 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 && ( +
{ + // 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, + }} + > +
e.stopPropagation()} + className="card" + style={{ padding: 24, maxWidth: 440, width: '100%' }} + > +
+ {isZh + ? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}?` + : `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`} +
+
+ {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.'} +
+
+
+ {isZh ? '开仓价' : 'Entry'} + {fmtMoney(closing.entry_price)} +
+
+ {isZh ? '现价' : 'Current price'} + + {closing.current_price != null + ? fmtMoney(closing.current_price) + : isZh + ? '暂无' + : 'n/a'} + +
+ {closing.realized_usd != null && closing.realized_usd !== 0 && ( +
+ {isZh ? '已落袋(之前减仓)' : 'Already banked'} + {fmtMoney(closing.realized_usd, { sign: true })} +
+ )} +
+ {isZh ? '平仓后将结算' : "You'll get if you close now"} + 0 + ? 'var(--up)' + : (closing.unrealized_usd ?? 0) < 0 + ? 'var(--down)' + : 'var(--ink-2)', + fontVariantNumeric: 'tabular-nums', + }} + > + {fmtMoney(closing.unrealized_usd, { sign: true })} + + ({fmtPct(closing.unrealized_pct)}) + + +
+
+ {closeState === 'err' && ( +
{closeErr}
+ )} +
+ + +
+
+
+ )} +
+ ) +} diff --git a/apps/web/components/seo/Breadcrumbs.tsx b/apps/web/components/seo/Breadcrumbs.tsx new file mode 100644 index 0000000..4e61ed5 --- /dev/null +++ b/apps/web/components/seo/Breadcrumbs.tsx @@ -0,0 +1,34 @@ +// Emits a schema.org/BreadcrumbList as JSON-LD. Server-component friendly — +// renders only a