ui: tighten dashboard copy and fix layout issues

This commit is contained in:
k
2026-06-14 21:58:56 +08:00
parent 4c3c8c6f87
commit 8534d90589
19 changed files with 1036 additions and 345 deletions
+378
View File
@@ -0,0 +1,378 @@
# Trump Alpha — Frontend (trumpsignal.com)
> Next.js 16 dashboard for the Trump Alpha backend. Real-time signals,
> Hyperliquid position management UI, KOL talks-vs-trades view, Macro Vibes
> regime panel. Read-only for the public; wallet-bound features (Pro) for
> auto-trade subscribers.
This is the AI-readable entry doc. Read this first on entering the repo.
---
## What this repo is
- **Pure frontend** — Next.js 16 App Router, TypeScript, server components +
client components, deployed to Vercel.
- **Talks to a Python backend** at `https://api.trumpsignal.com` (or
`localhost:8000` in dev). All trading state, signals, AI scoring,
Hyperliquid integration lives in the sibling **`../backend`**
repo. See its AGENTS.md.
- **No server-side trading logic** lives here. Frontend is a thin layer
over the API + WebSocket — even "open a position" routes to a backend
endpoint that does the signed-request verification + HL call.
---
## Stack quirks (Next.js 16 specific)
- **`proxy.ts` replaces `middleware.ts`** in Next.js 16. Routes that need
CORS / locale rewriting go through `proxy.ts` at the root.
- **`params` is async (`Promise<{...}>`)** in all dynamic routes. ALL page
components await it:
```tsx
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
...
}
```
Forgetting the `await` redirects to `/undefined/...` silently. Several
pages were broken this way pre-v2.0 — be alert if you copy older patterns.
- **i18n is shelved.** The `[locale]` segment exists, `messages/zh.json`
exists, but Chinese branches are kept as dead code with a comment
`// i18n shelved — see messages/zh.json`. Don't activate without an ADR.
Currently `isZh = false` is hardcoded in metadata generators.
- **`runtime = 'edge'`** on a few routes (`app/icon.tsx`, OG image generator).
Edge runtime has no fs / no Node APIs — don't import server-side libs there.
---
## Route map
```
app/
├── layout.tsx Root layout: JSON-LD schema.org, fonts, meta
├── page.tsx / (root) → marketing / landing page
├── icon.tsx Dynamic favicon (α on dark bg, edge runtime)
├── apple-icon.tsx PWA install icon
├── opengraph-image.tsx OG card generator (1200x630)
├── manifest.ts PWA manifest
├── sitemap.ts SEO sitemap.xml
├── robots.ts robots.txt
└── api/
└── proxy/[...path]/ Catch-all proxy to backend (CORS workaround)
app/[locale]/
├── page.tsx Landing — pinned signals, live ticker, Trump feed
├── DashboardClient.tsx Main 'use client' wrapper for the home dashboard
├── trump/page.tsx /en/trump — Trump Truth Social signal stream
├── macro/page.tsx /en/macro — Macro Vibes regime panel ★ renamed from /btc
├── kol/page.tsx /en/kol — KOL talks-vs-trades
├── trades/page.tsx /en/trades — closed trade history
├── posts/page.tsx /en/posts — all Posts (signal + Trump)
├── archive/page.tsx /en/archive — historical / paginated
├── settings/page.tsx /en/settings — wallet connect, HL key, prefs
├── analytics/page.tsx /en/analytics — performance metrics
├── case-studies/page.tsx /en/case-studies — documented event walkthroughs
├── methodology/page.tsx /en/methodology — how signals work
├── glossary/page.tsx /en/glossary — AHR999, MVRV-Z, KOL, etc.
├── privacy/page.tsx
├── terms/page.tsx
├── contact/page.tsx
└── globals.css Design system tokens + .macro-* classes
```
---
## Components map (the meaningful ones)
```
components/
├── nav/Navbar.tsx Top nav + tabs (Trump | Macro Vibes | KOL ...)
├── signals/
│ ├── SourceChips.tsx Filter chips per source
│ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch
├── dashboard/
│ ├── PostCards.tsx Trump post cards
│ ├── SignalMonitor.tsx Breakout Monitor (ETH/LINK 5m, WS-driven).
│ │ UNMOUNTED 2026-06-12 — backend scanner is
│ │ disabled and its 5-min poll job unscheduled.
│ │ Kept for revival; see Open known issues.
│ └── ChartPanel.tsx Price chart panel
├── btc/MacroPanel.tsx ★ 8-indicator Macro Vibes layout (4 sections,
│ composite needle, threshold chips, peak-trail viz)
├── positions/
│ └── OpenPositions.tsx Polls /positions/open + /positions/today every 15s
│ Includes grow toggle + manual close button
├── trades/
│ ├── TradeTable.tsx Closed trade history table (dynamic asset filter)
│ └── BotConfigPanel.tsx Bot configuration (SL, TP, leverage, paper mode).
│ Includes 3-step onboarding stepper + inline Auto-Trade toggle.
├── telegram/
│ └── TelegramCard.tsx Settings — connect via 6-char code
├── wallet/
│ └── SignConfirmSheet.tsx EIP-191 signed request preview
├── seo/Breadcrumbs.tsx Structured breadcrumb nav for SEO
├── ui/
│ ├── InfoTip.tsx CSS-only tooltip with `?` icon
│ ├── PageHint.tsx Strong page subtitle
│ ├── Pagination.tsx Reusable pagination control
│ └── TradeAlertBanner.tsx Fixed-position WS alert banner (trade_alert events).
│ Auto-dismisses after 10s. Filtered by wallet address.
└── lib/wsContext.tsx WebSocket singleton context (replaces WsProvider)
```
---
## API layer
All API calls go through **`lib/api.ts`**. Single `fetchJson<T>` wrapper:
- Reads base URL from env (`NEXT_PUBLIC_API_URL`) or proxies through
`/api/proxy/[...path]/` for same-origin CORS-free calls.
- Wallet-scoped reads (`/user/.../public`, `/positions/open`) pass wallet
as query param + signed ts/sig.
- Mutations (subscribe, manual_close, set_auto_trade, manual-window) build
an EIP-191 signed payload via wagmi → POST signed envelope.
**Auth model**: no sessions, no cookies. Wallet address + signed timestamp
+ signature on every mutating call. Verification lives in the backend
(`signed_request.verify_signed_request`). The same wallet that signed the
HL API key envelope at subscribe time is the only one that can later
mutate the subscription.
---
## State management
- **One small global store** — `store/dashboard.ts` (Zustand, `useDashboardStore`).
Holds cross-page UI/session state: selected post, chart asset/timeframe,
wallet address, subscription + bot-readiness flags, masked HL key hint, and
the live-price map. Everything else stays tree-local (state + props) — don't
grow this store into a catch-all; page-specific data belongs in the page.
- **SWR-style cache** in `lib/cache.ts` for the API-heavy pages.
- **WebSocket** singleton via `WsProvider` — used by `SignalMonitor` and
live price tickers.
- **localStorage** holds wallet address only (for re-connect UX). HL API
keys NEVER touch the client — they're submitted once at subscribe, sent
encrypted, and decrypted only inside the backend.
---
## Design system
Tokens in `app/[locale]/globals.css`:
- **Colors** — `oklch()` color space throughout. Brand orange = `#f5a524`,
brand dark = `#0a0907`. `var(--up)` green / `var(--down)` red / `var(--ink-1..3)`
text tiers.
- **Dark mode** via `[data-theme="dark"]` attribute on `<html>`. Most
components use `oklch(L% c h)` so dark/light switching is automatic.
- **Macro Vibes specific** — extensive `.macro-*` classes for the 8-indicator
layout. Threshold chips have `:not(.active) { opacity: 0.45 }` and `.active`
is tone-filled (green/red/amber).
---
## Things that LOOK like bugs but aren't
- **Old code references "/en/btc"** — Macro Vibes was renamed from "BTC
Signal" in v2.0. All routes are now `/en/macro`. `app/[locale]/btc/page.tsx`
EXISTS intentionally as a permanent redirect to `/en/macro` — that's fine.
If you find a stale `/en/btc` hardcoded link anywhere else, it's a real
bug — fix it.
- **`isZh = false` in metadata generators** — i18n was shelved pre-launch.
See "Stack quirks" above.
- **Multiple `proxy.ts` patterns** — Next.js 16 split middleware behaviour.
Keep proxy logic in the single `proxy.ts` at root.
- **`'use client'` directives on many pages** — necessary for client-side
WS / wallet interactions. Don't try to convert without verifying the WS
+ wagmi imports work in server components.
- **`telegram.send_message` accepting `int | str` for chat_id** — the backend
uses string form for the public channel (`"@trumpalpha"`) and integer for
private user chats. Both are valid; no frontend change needed.
- **`x-forwarded-for` is deleted in the proxy** — KNOWN BUG (see below).
Don't "fix" it by just removing the delete; the correct fix is to relay the
real client IP. See Open Known Issues.
---
## Open known issues (frontend)
- ~~**Rate limit bypass via proxy** (FIXED 2026-05-29):~~ `route.ts` no longer
deletes `x-forwarded-for`. It now reads the real client IP from the incoming
`x-forwarded-for` or `x-real-ip` header (set by Vercel/CDN) and sets it on
the upstream request so `slowapi` can distinguish individual clients.
- ~~**`signMessageAsync` in `OpenPositions` polling deps** (MEDIUM, FIXED 2026-05-29):~~
Removed `signMessageAsync` and `isZh` from the `useEffect` dependency array
in `components/positions/OpenPositions.tsx`. Neither is used inside the effect;
including them caused wagmi reference churn to restart the 15s poll timer.
- ~~**Stale types after BUG-08/BUG-14** (FE-0106, FIXED 2026-05-29):~~
- `types/index.ts`: `price_impact.asset` widened from `'BTC' | 'ETH'` to
`string` (BUG-14 now tracks the actually-traded asset which may be
SOL/TRUMP/etc.).
- `lib/useRealtimeData.ts`: `PriceMsg.asset` and `Handlers.onPrice` widened
to `string` (BUG-08 expanded the WS price stream to all ASSET_MAP entries).
- `store/dashboard.ts`: `livePrices` changed from `{ BTC; ETH }` to
`Record<string, number | null>`; `setLivePrice` param widened to `string`.
- `lib/api.ts`: `getPrices` asset param widened to `string`.
- `components/trades/TradeTable.tsx`: hardcoded `ASSETS = ['all','BTC','ETH','SOL']`
replaced by a dynamic `useMemo` that builds the asset list from the actual
trade set — new perps (TRUMP, BNB, etc.) appear in the filter automatically.
- `DashboardClient.tsx` + `TradesPageClient.tsx`: removed `isZh` from
`useEffect` deps (compile-time constant; including it restarted effects
on every render).
- ~~**Interaction/button bugs sweep** (UI-A..G, FIXED 2026-05-29):~~
- **A** `OpenPositions.tsx` close-modal backdrop now dismisses in `'err'`
state too (previously stuck — only `'idle'` dismissed). State is reset on
dismiss so the next open is clean.
- **B** `confirmCloseTrade` distinguishes user-cancelled signature
(`isUserRejection`) from real failure: cancellation closes the modal
silently; real errors stay open in `'err'` state with the message.
- **C** `SystemControl.flipAuto` claims `busy=true` **before** `await
confirmSign(...)`. Rapid double-click on the ON/OFF pill no longer slips
past the guard. The slot is released if the user cancels the sheet.
- **D** `BotConfigPanel` "Rotate" API-key button resets `keyState`/`keyErr`
so a previously-failed save's red error doesn't bleed into the new edit.
- **E** Paper/Live `Switch` clears `subErr` when toggled so a stale
subscribe-error doesn't sit next to the new choice.
- **F** `confirmSign` keeps a module-scope `_activeCancel`; a second call
while a sheet is mounted resolves the first promise with `false` instead
of leaving its caller hanging forever. All 9 call sites verified to
cleanly handle the `false` return (`setBusy(false)` / `setLoadState('idle')`).
- **G** `OpenPositions.toggleGrow` errors now write to a dedicated
`growErr` state with a 4-second auto-clear, instead of squatting in the
panel-header `err` banner shared with the 15-s poll error.
- ~~**H2 Chart data race** (FIXED 2026-06-01):~~ `cancelled` flag added to the
candles `useEffect` — stale BTC response can no longer overwrite ETH candles
when asset is switched mid-fetch.
- ~~**M8 Duplicate React keys** (FIXED 2026-06-01):~~ `onNewPost` now deduplicates
by `post.id` before prepending to the posts list.
- ~~**L2 fittedRef not reset on asset change** (FIXED 2026-06-01):~~ Chart view
is re-fitted when `asset` changes, not only on `timeframe` change.
- ~~**L4 invalid ARIA role** (FIXED 2026-06-01):~~ `Ticker.tsx` `role="marquee"`
replaced with `role="region"`.
- ~~**L5 ws:// mixed-content block** (FIXED 2026-06-01):~~ `wsContext.tsx` now
auto-upgrades to `wss://` when served over HTTPS, instead of hard-coding `ws://`.
- ~~**Data-consistency / UI sweep** (FIXED 2026-06-09):~~
- **Overview headline counts** (`DashboardClient`) now come from server-side
`/posts-paged` totals (`getPostsPage(1,1,...)` for global / truth / macro),
not the 80-post first-paint slice. The local slice is only a pre-load
fallback. Without this a 1108-post feed showed "80 tracked / —".
- **"Live" chip downgrades to "Delayed"** when `/api/health/deep` reports
`is_leader === false` (follower process runs no background tasks → data is
frozen). Keyed ONLY on is_leader, not `status:degraded`, to avoid flapping
on transient single-feed staleness.
- **KOL pages default to a 30d window** (was 7d) everywhere — `kol/page.tsx`
SSR, `KolPageClient` `kolDays`, and the Overview KOL-intel card. KOL
ingestion is daily/sparse so 7d was frequently empty (7d=0 / 30d=196).
- **KOL divergence "Xd ago"** uses `post_at`, not `created_at` (a backfill
set created_at≈now and made old events look fresh).
- **Macro tone** is derived from the backend `regime_label`, not a
re-thresholded score (±15 on the client vs ±20 on the server disagreed).
- **Trump page header count** follows the active signal filter.
- **Source labels**: `blog` (KOL), `sma_reclaim`/`rsi_reversal`/`breakout`
(TradeTable + PostCards), `adopted` (TradeTable) registered — no more raw
technical strings.
- **TradeTable** resets source/asset filters that don't apply to the new
wallet's trade set (clamp effect), so a stuck filter + vanished breakdown
card can't hide the new history.
- **OpenPositions** has an in-page "Unlock positions" button (signs
`view_user`) — no forced detour to Settings.
- **BotConfigPanel**: overnight schedules (22:0002:00) are allowed (backend
already supports wrap-around); readiness reports NOT-ready for a Macro-only
wallet with no Telegram bound (since `/adopt` needs Telegram).
- **Cross-page selection**: clicking a Recent-signals row clears
`selectedDayPosts` so the single-post detail actually shows.
**Open / deferred (need interface change or DB migration):**
- **C3** Signed READ endpoints (`allow_replay=True`) put `ts`/`sig` in URL → access logs. Fix requires changing read endpoints to POST body (interface change + frontend update).
- **H4** KEK derived with single SHA-256, no salt/KDF. Fix requires re-encrypting all HL API keys (DB migration).
- **M5** `GET /telegram/{wallet}/status` is unauthenticated — exposes `chat_id`/`tg_username`.
- **M7** Rate limit bypassable via spoofed XFF — infrastructure-level fix (nginx/Cloudflare).
- ~~**M9**~~ **FIXED 2026-06-12**: `lib/cache.ts` keeps a per-key `subscribers`
list — every caller that hits a stale key during an in-flight revalidation
registers its `onUpdate`, and ALL are notified when the fetch resolves
(previously only the caller that started the revalidation got fresh data).
- ~~**Funding Reversal tab Breakout Monitor**~~ **RESOLVED 2026-06-12 (removed)**:
`SignalMonitor` unmounted from the Funding tab — the backend scanner has been
disabled for months (`funding_signal._enabled=False`, operator-only toggle) so
the panel only ever showed "Paused / No signals yet". The backend's 5-min poll
job was also unscheduled (`backend/app/main.py`); the `/signal/*` API routes
remain. To revive: re-add the `add_job()` AND remount `SignalMonitor` in
`MacroVibesPageClient`.
## How to verify changes locally
```bash
# Type check (must pass with 0 errors)
npx tsc --noEmit
# Production build (catches missing pages, stale imports)
rm -rf .next && npx next build
# Dev server — NOTE: this app runs on port 3001 (see package.json:
# `next dev -p 3001` / `next start -p 3001`), NOT the Next.js default 3000.
# Any external doc, script, or healthcheck must target 3001.
npm run dev
# Pages-200 sweep (every locale page returns 200) — port 3001
for p in /en /en/trump /en/macro /en/kol /en/trades /en/posts /en/settings \
/en/methodology /en/glossary /en/case-studies /en/privacy /en/terms; do
echo "$p $(curl -so /dev/null -w '%{http_code}' http://localhost:3001$p)"
done
```
---
## SEO / GEO surface (don't break)
- **`app/layout.tsx`** has JSON-LD schema.org markup (Organization,
SoftwareApplication, FAQ). Search engines + AI crawlers read this.
- **`public/llms.txt`** — canonical doc for AI crawlers (Perplexity, ChatGPT
browse, Google AI Overviews). Edit BOTH this file AND `layout.tsx` when
renaming product features.
- **`app/sitemap.ts`** — sitemap.xml. Add new routes here.
- **OG image** — `app/opengraph-image.tsx` dynamically renders the social
card. Keep the brand pill list in sync with current features.
---
## Coupling to backend
- **API contract lives in TypeScript types under `lib/api.ts`** (e.g.
`OpenPositionsResponse`, `TodayStats`, `BotTrade`). These must match the
backend Pydantic models in `app/api/*/positions.py` etc.
- When backend adds a field (e.g. `BotTrade.released_at`), this repo's
`lib/api.ts` types may need updating to surface it in the UI. Currently
released trades are FILTERED OUT by the backend's `/positions/open` —
they don't appear here, so no UI for "managed vs released" yet.
- When backend renames a route (e.g. `/btc/page.tsx` → `/macro/page.tsx`),
search for ALL references (including telegram deep-link map in
`backend/app/services/telegram.py`).
---
## Deploy
Vercel auto-deploys `main` branch pushes. No special build flags. Env vars
in Vercel dashboard:
- `NEXT_PUBLIC_API_URL` — backend HTTPS origin
- `NEXT_PUBLIC_SITE_URL` — canonical site URL (used for OG, sitemap)
- `NEXT_PUBLIC_WS_URL` — WebSocket endpoint (usually `wss://api...`)
---
## Sibling repo
- **`/Users/k/Public/trumpsignal/backend`** — Python/FastAPI backend, includes
Hyperliquid integration, Telegram bot, AI scoring (DeepSeek), all
signal scanners, the adoption/release flow. See its AGENTS.md.
+46 -3
View File
@@ -93,7 +93,10 @@ components/
│ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch │ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch
├── dashboard/ ├── dashboard/
│ ├── PostCards.tsx Trump post cards │ ├── PostCards.tsx Trump post cards
│ ├── SignalMonitor.tsx Live signal stream (WS-driven) │ ├── SignalMonitor.tsx Breakout Monitor (ETH/LINK 5m, WS-driven).
│ │ UNMOUNTED 2026-06-12 — backend scanner is
│ │ disabled and its 5-min poll job unscheduled.
│ │ Kept for revival; see Open known issues.
│ └── ChartPanel.tsx Price chart panel │ └── ChartPanel.tsx Price chart panel
├── btc/MacroPanel.tsx ★ 8-indicator Macro Vibes layout (4 sections, ├── btc/MacroPanel.tsx ★ 8-indicator Macro Vibes layout (4 sections,
│ composite needle, threshold chips, peak-trail viz) │ composite needle, threshold chips, peak-trail viz)
@@ -259,13 +262,53 @@ Tokens in `app/[locale]/globals.css`:
- ~~**L5 ws:// mixed-content block** (FIXED 2026-06-01):~~ `wsContext.tsx` now - ~~**L5 ws:// mixed-content block** (FIXED 2026-06-01):~~ `wsContext.tsx` now
auto-upgrades to `wss://` when served over HTTPS, instead of hard-coding `ws://`. auto-upgrades to `wss://` when served over HTTPS, instead of hard-coding `ws://`.
- ~~**Data-consistency / UI sweep** (FIXED 2026-06-09):~~
- **Overview headline counts** (`DashboardClient`) now come from server-side
`/posts-paged` totals (`getPostsPage(1,1,...)` for global / truth / macro),
not the 80-post first-paint slice. The local slice is only a pre-load
fallback. Without this a 1108-post feed showed "80 tracked / —".
- **"Live" chip downgrades to "Delayed"** when `/api/health/deep` reports
`is_leader === false` (follower process runs no background tasks → data is
frozen). Keyed ONLY on is_leader, not `status:degraded`, to avoid flapping
on transient single-feed staleness.
- **KOL pages default to a 30d window** (was 7d) everywhere — `kol/page.tsx`
SSR, `KolPageClient` `kolDays`, and the Overview KOL-intel card. KOL
ingestion is daily/sparse so 7d was frequently empty (7d=0 / 30d=196).
- **KOL divergence "Xd ago"** uses `post_at`, not `created_at` (a backfill
set created_at≈now and made old events look fresh).
- **Macro tone** is derived from the backend `regime_label`, not a
re-thresholded score (±15 on the client vs ±20 on the server disagreed).
- **Trump page header count** follows the active signal filter.
- **Source labels**: `blog` (KOL), `sma_reclaim`/`rsi_reversal`/`breakout`
(TradeTable + PostCards), `adopted` (TradeTable) registered — no more raw
technical strings.
- **TradeTable** resets source/asset filters that don't apply to the new
wallet's trade set (clamp effect), so a stuck filter + vanished breakdown
card can't hide the new history.
- **OpenPositions** has an in-page "Unlock positions" button (signs
`view_user`) — no forced detour to Settings.
- **BotConfigPanel**: overnight schedules (22:0002:00) are allowed (backend
already supports wrap-around); readiness reports NOT-ready for a Macro-only
wallet with no Telegram bound (since `/adopt` needs Telegram).
- **Cross-page selection**: clicking a Recent-signals row clears
`selectedDayPosts` so the single-post detail actually shows.
**Open / deferred (need interface change or DB migration):** **Open / deferred (need interface change or DB migration):**
- **C3** Signed READ endpoints (`allow_replay=True`) put `ts`/`sig` in URL → access logs. Fix requires changing read endpoints to POST body (interface change + frontend update). - **C3** Signed READ endpoints (`allow_replay=True`) put `ts`/`sig` in URL → access logs. Fix requires changing read endpoints to POST body (interface change + frontend update).
- **H4** KEK derived with single SHA-256, no salt/KDF. Fix requires re-encrypting all HL API keys (DB migration). - **H4** KEK derived with single SHA-256, no salt/KDF. Fix requires re-encrypting all HL API keys (DB migration).
- **M1** Adopted (sys2) positions incorrectly counted against sys1 daily budget.
- **M5** `GET /telegram/{wallet}/status` is unauthenticated — exposes `chat_id`/`tg_username`. - **M5** `GET /telegram/{wallet}/status` is unauthenticated — exposes `chat_id`/`tg_username`.
- **M7** Rate limit bypassable via spoofed XFF — infrastructure-level fix (nginx/Cloudflare). - **M7** Rate limit bypassable via spoofed XFF — infrastructure-level fix (nginx/Cloudflare).
- **M9** SWR cache only notifies the first caller on background refresh — others get stale data. - ~~**M9**~~ **FIXED 2026-06-12**: `lib/cache.ts` keeps a per-key `subscribers`
list — every caller that hits a stale key during an in-flight revalidation
registers its `onUpdate`, and ALL are notified when the fetch resolves
(previously only the caller that started the revalidation got fresh data).
- ~~**Funding Reversal tab Breakout Monitor**~~ **RESOLVED 2026-06-12 (removed)**:
`SignalMonitor` unmounted from the Funding tab — the backend scanner has been
disabled for months (`funding_signal._enabled=False`, operator-only toggle) so
the panel only ever showed "Paused / No signals yet". The backend's 5-min poll
job was also unscheduled (`backend/app/main.py`); the `/signal/*` API routes
remain. To revive: re-add the `add_job()` AND remount `SignalMonitor` in
`MacroVibesPageClient`.
## How to verify changes locally ## How to verify changes locally
+214 -60
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
@@ -9,13 +9,14 @@ import { useAccount } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types' import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard' import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData' import { usePriceSocket } from '@/lib/useRealtimeData'
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, type MacroSnapshot } from '@/lib/api' import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, getPostsPage, type MacroSnapshot } from '@/lib/api'
import type { KolDivergence, KolDigest } from '@/types' import type { KolDivergence, KolDigest } from '@/types'
import { getCachedViewEnvelope } from '@/lib/signedRequest' import { getCachedViewEnvelope } from '@/lib/signedRequest'
import { swrFetch, invalidate as invalidateCache } from '@/lib/cache' import { swrFetch, invalidate as invalidateCache } from '@/lib/cache'
import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards' import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
import OpenPositions from '@/components/positions/OpenPositions' import OpenPositions from '@/components/positions/OpenPositions'
import PageHint from '@/components/ui/PageHint' import PageHint from '@/components/ui/PageHint'
import InfoTip from '@/components/ui/InfoTip'
import AnimatedNumber from '@/components/ui/AnimatedNumber' import AnimatedNumber from '@/components/ui/AnimatedNumber'
// Heavy components — lazy-loaded so they don't bloat the initial JS bundle. // Heavy components — lazy-loaded so they don't bloat the initial JS bundle.
@@ -159,17 +160,42 @@ export default function DashboardClient({ initialPosts }: Props) {
const params = useParams() const params = useParams()
const locale = (typeof params?.locale === 'string' ? params.locale : 'en') const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts) const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
// Chart markers need actionable (buy/short) posts, but the 80-post
// first-paint slice is usually ALL hold/filtered — Trump posts are mostly
// off-topic, so the chart would render zero markers. Fetch signal posts
// separately and merge them in for the chart.
const [signalPosts, setSignalPosts] = useState<TrumpPost[]>([])
// Distinguishes "actionable fetch not done yet" from "loaded, zero
// actionable" — the auto-select effect waits on it before falling back.
const [signalPostsLoaded, setSignalPostsLoaded] = useState(false)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined) const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([]) const [candles, setCandles] = useState<Candle[]>([])
const [hideFiltered, setHideFiltered] = useState(false)
const [chartErr, setChartErr] = useState('') const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0) const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null) const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
const railRef = useRef<HTMLDivElement>(null)
const [macro, setMacro] = useState<MacroSnapshot | null>(null) const [macro, setMacro] = useState<MacroSnapshot | null>(null)
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([]) const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null) const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
// For 1D: show all posts from selected day // For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null) const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
// Server-side aggregate counts. The Overview headline numbers (total tracked,
// actionable, Trump/Macro signals) must reflect the WHOLE feed, not the
// 80-post first-paint slice — otherwise a site with 1108 posts / 25 actionable
// Trump signals showed "80 tracked" and "—". Loaded once from /posts-paged
// which returns server-computed totals + counts without shipping the rows.
const [serverCounts, setServerCounts] = useState<{
total: number; actionable: number; trump: number; macro: number
} | null>(null)
// Backend degraded state. When the process isn't the singleton leader it
// runs NO background tasks (no scrapers, price feeds, scheduler) — the data
// is frozen and "Live" would be a lie. /api/health/deep returns 503 + a body
// with { status, is_leader } in that case.
const [degraded, setDegraded] = useState(false)
useEffect(() => { useEffect(() => {
// B42: cancel the in-flight getUserPublic if the wallet switches again // B42: cancel the in-flight getUserPublic if the wallet switches again
// before it resolves — prevents old wallet's data polluting new wallet's state. // before it resolves — prevents old wallet's data polluting new wallet's state.
@@ -199,7 +225,7 @@ export default function DashboardClient({ initialPosts }: Props) {
}) })
.catch(() => {}) .catch(() => {})
return () => { cancelled = true } return () => { cancelled = true }
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness]) }, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode])
useEffect(() => { useEffect(() => {
if (!isConnected || !address) { if (!isConnected || !address) {
@@ -255,9 +281,63 @@ export default function DashboardClient({ initialPosts }: Props) {
.then(c => { if (!cancelled) { setCandles(c); setChartErr('') } }) .then(c => { if (!cancelled) { setCandles(c); setChartErr('') } })
.catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') }) .catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') })
return () => { cancelled = true } return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [asset, timeframe, chartReload]) }, [asset, timeframe, chartReload])
// Auto-select the latest actionable signal once on load so the detail rail
// isn't an empty column until the user clicks something. Runs once; never
// re-selects after the user picks or closes a post.
const autoSelectedRef = useRef(false)
const skipRailScrollRef = useRef(false)
useEffect(() => {
if (autoSelectedRef.current) return
if (selectedPostId != null || selectedDayPosts) { autoSelectedRef.current = true; return }
const pool = [...posts, ...signalPosts]
const actionable = pool.filter(p => p.signal === 'buy' || p.signal === 'short')
// Prefer the latest actionable signal — but the actionable fetch is async,
// so wait for it before falling back to a (likely off-topic) recent post.
if (!actionable.length && !signalPostsLoaded) return
const pick = (actionable.length ? actionable : pool)
.slice()
.sort((a, b) => +new Date(b.published_at) - +new Date(a.published_at))[0]
if (!pick) return
autoSelectedRef.current = true
skipRailScrollRef.current = true // a load-time selection must not scroll the page
setSelectedPostId(pick.id)
}, [posts, signalPosts, signalPostsLoaded, selectedPostId, selectedDayPosts])
// On narrow viewports the layout stacks and the detail rail sits ABOVE the
// chart — a click on a post down in the list renders the detail card out of
// view and the selection looks like a no-op. Scroll it into view, but only
// when it's actually outside the viewport (no jump on desktop, where the
// rail is a sticky side column).
useEffect(() => {
if (selectedPostId == null && !selectedDayPosts) return
if (skipRailScrollRef.current) { skipRailScrollRef.current = false; return }
const el = railRef.current
if (!el) return
const r = el.getBoundingClientRect()
// The detail card renders at the TOP of the rail, so "visible" means the
// rail's top edge is on screen — a rail whose tail end pokes into the
// viewport still hides the card.
if (r.top < 0 || r.top > window.innerHeight - 120) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [selectedPostId, selectedDayPosts])
// Actionable posts for the chart markers (see signalPosts above).
useEffect(() => {
let alive = true
swrFetch(
'posts-actionable-chart',
90_000,
() => getPostsPage(100, 1, undefined, { signal: 'actionable' }),
fresh => { if (alive) setSignalPosts(fresh.items) },
)
.then(r => { if (alive) { setSignalPosts(r.items); setSignalPostsLoaded(true) } })
.catch(() => { if (alive) setSignalPostsLoaded(true) })
return () => { alive = false }
}, [])
useEffect(() => { useEffect(() => {
let alive = true let alive = true
function load() { function load() {
@@ -275,6 +355,50 @@ export default function DashboardClient({ initialPosts }: Props) {
return () => { alive = false; clearInterval(id) } return () => { alive = false; clearInterval(id) }
}, []) }, [])
// Poll backend health so the "Live" chip can downgrade to "Delayed" when the
// process is a non-leader follower (read-only, no background tasks → data is
// frozen). The body is present on both 200 and 503, so read it regardless of
// status. Key ONLY on is_leader === false: a `degraded` status can also mean
// a single price feed briefly lagged, which would flap the chip even though
// the feed self-heals in seconds — that's noisier than it is useful.
useEffect(() => {
let alive = true
async function check() {
try {
const res = await fetch('/api/proxy/api/health/deep')
const body = await res.json().catch(() => null)
if (!alive || !body) return
setDegraded(body.is_leader === false)
} catch { /* network error — leave chip as-is */ }
}
check()
const id = setInterval(check, 60_000)
return () => { alive = false; clearInterval(id) }
}, [])
// Server-side aggregate counts for the Overview headline numbers. limit=1
// keeps the payload tiny — we only consume `total` and `counts`.
useEffect(() => {
let alive = true
;(async () => {
try {
const [global, trump, macro] = await Promise.all([
getPostsPage(1, 1),
getPostsPage(1, 1, 'truth'),
getPostsPage(1, 1, undefined, { sourceIn: ['btc_bottom_reversal', 'funding_reversal'] }),
])
if (!alive) return
setServerCounts({
total: global.total,
actionable: global.counts.actionable,
trump: trump.counts.actionable,
macro: macro.counts.actionable,
})
} catch { /* fall back to local-slice counts */ }
})()
return () => { alive = false }
}, [])
// KOL divergence + digest — loaded once, 30 min cache (changes daily) // KOL divergence + digest — loaded once, 30 min cache (changes daily)
useEffect(() => { useEffect(() => {
let alive = true let alive = true
@@ -290,12 +414,21 @@ export default function DashboardClient({ initialPosts }: Props) {
return () => { alive = false } return () => { alive = false }
}, []) }, [])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null // Union of the recent-posts slice and the separately-fetched actionable
// posts (deduped) — feeds the chart so signal markers always render, and
// the detail lookup so clicking one of those markers resolves.
const chartPosts = (() => {
const seen = new Set(posts.map(p => p.id))
return [...posts, ...signalPosts.filter(p => !seen.has(p.id))]
})()
const selectedPost = chartPosts.find(p => p.id === selectedPostId) ?? null
// Show actionable signals first (buy/short), then most recent hold/neutral. // Show actionable signals first (buy/short), then most recent hold/neutral.
// Cap at 8 total so the list doesn't get too long. // Cap at 8 total so the list doesn't get too long. "Signals only" hides the
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4) // hold/filtered (gray) posts entirely — same toggle as the Trump page.
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4) const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, hideFiltered ? 8 : 4)
const recentOthers = hideFiltered ? [] : posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
const recentPosts = [...actionable, ...recentOthers].slice(0, 8) const recentPosts = [...actionable, ...recentOthers].slice(0, 8)
const lastCandle = candles[candles.length - 1] const lastCandle = candles[candles.length - 1]
@@ -322,12 +455,14 @@ export default function DashboardClient({ initialPosts }: Props) {
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100 ? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
: 0 : 0
const totalPosts = posts.length // Prefer server-computed totals (whole feed); fall back to the local 80-post
// slice only until serverCounts loads (or if that fetch failed).
const totalPosts = serverCounts?.total ?? posts.length
const todayKey = new Date().toISOString().slice(0, 10) const todayKey = new Date().toISOString().slice(0, 10)
const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length
const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length const actionablePosts = serverCounts?.actionable ?? posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length const trumpActionable = serverCounts?.trump ?? posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length const macroActionable = serverCounts?.macro ?? posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length
// Use actual KOL divergence count (divergence-flagged items from the API), // Use actual KOL divergence count (divergence-flagged items from the API),
// not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.). // not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.).
const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length
@@ -343,10 +478,9 @@ export default function DashboardClient({ initialPosts }: Props) {
// a score of e.g. 15.2 read "Supportive" in the card while the same card's // a score of e.g. 15.2 read "Supportive" in the card while the same card's
// regime label said NEUTRAL. Trusting regime_label keeps them consistent. // regime label said NEUTRAL. Trusting regime_label keeps them consistent.
const macroTone = const macroTone =
macroRegime == null ? (macroScore == null ? 'neutral' : 'neutral') macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
: macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear' : macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
: 'neutral' : 'neutral' // covers NEUTRAL and the null/not-loaded case
const macroSummary = const macroSummary =
macroScore == null ? 'Daily macro composite not loaded yet.' macroScore == null ? 'Daily macro composite not loaded yet.'
: macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.' : macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.'
@@ -362,7 +496,14 @@ export default function DashboardClient({ initialPosts }: Props) {
Trump · Macro · KOL divergence live. Trump · Macro · KOL divergence live.
</PageHint> </PageHint>
</div> </div>
{degraded ? (
<span className="chip" title="Backend is running read-only (not the singleton leader) — no background tasks, values may be delayed."
style={{ color: 'var(--amber-ink)', borderColor: 'color-mix(in oklab, var(--amber) 35%, var(--line))' }}>
<span className="live-dot" style={{ background: 'var(--amber)' }} />Delayed
</span>
) : (
<span className="chip"><span className="live-dot" />Live</span> <span className="chip"><span className="live-dot" />Live</span>
)}
</div> </div>
{/* Open positions — what's on the book right now. Renders only when {/* Open positions — what's on the book right now. Renders only when
@@ -387,21 +528,6 @@ export default function DashboardClient({ initialPosts }: Props) {
</div> </div>
<div className="overview-market-subtitle">{asset} · live signal context</div> <div className="overview-market-subtitle">{asset} · live signal context</div>
</div> </div>
<div className="overview-controls">
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
</div> </div>
<div className={`overview-macro-card ${macroTone}`}> <div className={`overview-macro-card ${macroTone}`}>
@@ -488,17 +614,17 @@ export default function DashboardClient({ initialPosts }: Props) {
<section className="overview-side-card" style={{ padding: '14px 16px' }}> <section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div> <div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
{([ {([
{ icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed' }, { icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed', href: `/${locale}/trump` },
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds' }, { icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds', href: `/${locale}/settings` },
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access' }, { icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access', href: `/${locale}/trades` },
] as const).map(({ icon, head, sub }) => ( ] as const).map(({ icon, head, sub, href }) => (
<div key={head} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12 }}> <Link key={head} href={href} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12, textDecoration: 'none', color: 'inherit' }}>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span> <span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span>
<div> <div>
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head}</div> <div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head} <span style={{ color: 'var(--ink-4)', fontWeight: 400 }}></span></div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div> <div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div>
</div> </div>
</div> </Link>
))} ))}
</section> </section>
)} )}
@@ -532,7 +658,10 @@ export default function DashboardClient({ initialPosts }: Props) {
{' '}on <strong>{d.ticker}</strong> {' '}on <strong>{d.ticker}</strong>
</div> </div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}> <div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? `$${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''} {/* /1000+"k" alone printed "$2100k" for $2.1M positions */}
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null
? `${d.usd_after >= 1e6 ? '$' + (d.usd_after / 1e6).toFixed(1) + 'M' : '$' + (d.usd_after / 1e3).toFixed(0) + 'K'} on-chain`
: ''}
</div> </div>
</div> </div>
))} ))}
@@ -565,20 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) {
{/* Left: Chart + signal stream */} {/* Left: Chart + signal stream */}
<div className="stack gap-l"> <div className="stack gap-l">
<div className="card" style={{ padding: 24 }}> <div className="card" style={{ padding: 24 }}>
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}> {/* The big price lives in the market hero above — repeating it here
<div> just duplicated the same number 32px tall. The chart's own price
<div className="tiny">Price · {asset}</div> axis label shows the live price; this header instead hosts the
<div className="row gap-m" style={{ marginTop: 6 }}> asset/timeframe controls right next to the chart they drive. */}
<AnimatedNumber <div className="row between" style={{ marginBottom: 14, alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
className="hero-value mono" <div className="tiny">Live chart · {asset} · signal markers</div>
style={{ fontSize: 32 }} <div className="overview-controls">
value={displayPrice} <div className="asset-switch">
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'} <button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
/> <span className="asset-dot btc" /> BTC
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span> </button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div> </div>
</div> </div>
<div className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
</div> </div>
{chartErr && ( {chartErr && (
@@ -605,7 +741,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div> </div>
)} )}
<ChartPanel <ChartPanel
posts={posts} posts={chartPosts}
candles={candles} candles={candles}
externalSelectedId={selectedPostId} externalSelectedId={selectedPostId}
onSelectPost={(id) => { onSelectPost={(id) => {
@@ -620,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
</div> </div>
<div className="chart-footnote"> <div className="chart-footnote">
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div> <div className="item">
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div> Markers
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div> <InfoTip
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>} placement="top"
width={280}
text={asset === 'BTC'
? 'Green dot = buy, red = short; a number is the signal count in that candle. Amber dashed line = macro reversal signal.'
: 'Green dot = buy, red = short; a number is the signal count in that candle.'}
/>
</div>
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div> <div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
</div> </div>
</div> </div>
@@ -632,10 +774,22 @@ export default function DashboardClient({ initialPosts }: Props) {
<div> <div>
<div className="section-title"> <div className="section-title">
<h2>Recent signals</h2> <h2>Recent signals</h2>
<span className="hint"> <button
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''} onClick={() => setHideFiltered(v => !v)}
{`${totalPosts} tracked`} title="Hide hold/filtered (gray) posts and show only buy/short signals"
</span> style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideFiltered ? 'var(--ink)' : 'transparent',
color: hideFiltered ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginLeft: 'auto', marginRight: 8,
}}
>
{hideFiltered ? 'Show all' : 'Signals only'}
</button>
{/* No count hint here — the page-head PageHint already shows
"X actionable · Y tracked" for the whole feed. */}
</div> </div>
<div className="post-stream"> <div className="post-stream">
{recentPosts.map(p => ( {recentPosts.map(p => (
@@ -655,7 +809,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div> </div>
{/* Right rail */} {/* Right rail */}
<div className="rail"> <div className="rail" ref={railRef}>
{/* Day-view: all posts from clicked day */} {/* Day-view: all posts from clicked day */}
{selectedDayPosts ? ( {selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}> <div className="card" style={{ padding: 20 }}>
+59 -33
View File
@@ -238,43 +238,59 @@ export default function AnalyticsPageClient() {
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span> <span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span> <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}> {(() => {
{/* Overall */} // One full-width block per measurement window. Big overall number
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}> // + a hit-rate bar (50% tick = coin-flip baseline) + per-direction
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Overall</div> // breakdown. `repeat(3, 1fr)` stretches across the card so no
{(['m5','m15','m1h'] as const).map(w => { // whitespace is stranded on wide screens.
const d = accuracy.overall[w] const WINDOWS = ['m5', 'm15', 'm1h'] as const
const pct = d.accuracy_pct const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
const fmtBreakdown = (pct: number | null, sparse: boolean) =>
sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
// auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the
// viewport on phones and forced the whole page to scroll sideways.
return ( return (
<div key={w} className="row between" style={{ marginBottom: 3 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span> {WINDOWS.map(w => {
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct != null ? `${pct.toFixed(0)}%` : '—'}</span> const pct = accuracy.overall[w].accuracy_pct
const has = pct != null && !Number.isNaN(pct)
return (
<div key={w} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'flex', alignItems: 'center' }}>
{WINDOW_LABEL[w]}
<InfoTip text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`} placement="top" />
</div> </div>
) <div className="mono" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.01em', marginTop: 8, color: has ? pctColor(pct) : 'var(--ink-4)', fontVariantNumeric: 'tabular-nums' }}>
})} {has ? `${pct.toFixed(0)}%` : '—'}
</div> </div>
{Object.entries(accuracy.by_signal).map(([sig, data]) => { {/* hit-rate bar with a 50% coin-flip tick */}
<div style={{ position: 'relative', height: 6, background: 'var(--surface-3)', borderRadius: 999, marginTop: 10, overflow: 'hidden' }}>
{has && (
<div style={{ position: 'absolute', inset: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: pctColor(pct), borderRadius: 999, opacity: 0.75 }} />
)}
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1.5, background: 'var(--ink-4)', opacity: 0.55 }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: 'var(--ink-3)', flexWrap: 'wrap' }}>
{Object.entries(accuracy.by_signal).map(([sig, d]) => {
const sparse = d[w].checked < 2
const v = fmtBreakdown(d[w].accuracy_pct, sparse)
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell' const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
return ( return (
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}> <span key={sig} style={{ whiteSpace: 'nowrap' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div> {label}{' '}
{(['m5','m15','m1h'] as const).map(w => { <strong style={{ color: sparse ? 'var(--ink-4)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}>{v}</strong>
const d = data[w] <span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
if (d.checked < 2) return <div key={w} className="row between" style={{ marginBottom: 3 }}><span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span><span style={{ fontSize: 12, color: 'var(--ink-3)' }}></span></div> </span>
const pct = d.accuracy_pct )
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' })}
return ( </div>
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
</div> </div>
) )
})} })}
</div> </div>
) )
})} })()}
</div>
</div> </div>
)} )}
@@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}> <div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'} {filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
</div> </div>
{/* Win-rate chip only when there are trades to rate — a red
"0.0% win rate" next to an em-dash P&L misreads as a losing
record when the wallet simply has no data. */}
{pricedTrades.length > 0 && (
<div className="row gap-s" style={{ marginTop: 8 }}> <div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span> <span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
</div> </div>
)}
{filteredTrades.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 12, lineHeight: 1.6, maxWidth: 520 }}>
No closed bot trades in this window. P&amp;L, win rate and the
per-trade metrics below fill in once auto-trade executions close
or pick a longer window on the top right.
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -339,15 +366,14 @@ export default function AnalyticsPageClient() {
{/* Signal accuracy is now shown at the top of the page (public, no login needed). {/* Signal accuracy is now shown at the top of the page (public, no login needed).
Removed duplicate block here to avoid showing the same data twice. */} Removed duplicate block here to avoid showing the same data twice. */}
{!filteredTrades.length && ( {/* Empty-state card renders only for states no other element explains:
- "window empty but trades exist" → Performance hero's inline note
- privateLocked → the unlock card at the top of the page */}
{!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}> <div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}> <p style={{ fontSize: 14 }}>
{!isConnected || !address {!isConnected || !address
? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.') ? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.')
: privateLocked
? (isZh ? '签名解锁后可查看你的真实业绩和交易历史。' : 'Sign once above to unlock your personal P&L and trade history.')
: trades.length
? (isZh ? `${period} 时间窗内还没有已平仓交易。` : `No trades closed in the ${period} window yet.`)
: (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')} : (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}
</p> </p>
</div> </div>
+3 -4
View File
@@ -79,9 +79,6 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE)) const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE))
const archiveSafePage = Math.min(archivePage, archiveTotalPages) const archiveSafePage = Math.min(archivePage, archiveTotalPages)
const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0) const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0)
const selectedCount = src === 'all'
? totalPosts
: sourceCounts.find(s => s.source === src)?.count ?? totalPosts
const sourceTabs: [string, number][] = [ const sourceTabs: [string, number][] = [
['all', allSourcesCount], ['all', allSourcesCount],
...sourceCounts.map(item => [item.source, item.count] as [string, number]), ...sourceCounts.map(item => [item.source, item.count] as [string, number]),
@@ -92,7 +89,9 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">Archive</h1> <h1 className="page-title">Archive</h1>
<PageHint count={`${selectedCount} legacy posts`}> {/* No count slot — the source tabs and pagination already show
per-source and total counts. */}
<PageHint>
Signals from retired scanner experiments Signals from retired scanner experiments
(rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only (rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only
the bot no longer acts on any of these. the bot no longer acts on any of these.
+13 -2
View File
@@ -16,8 +16,19 @@ export default async function ArchivePage() {
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, { const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
filters: { archiveOnly: true }, filters: { archiveOnly: true },
legacyFallback: async () => { legacyFallback: async () => {
const legacyItems = await getPosts(500, 1).catch(() => null) // Old backend (no /posts-paged archive_only): /posts caps at 500 per
return legacyItems ? buildArchiveFallbackResponse(legacyItems, 1, 30) : null // page, so a single page only sees the latest 500 posts globally — older
// archive rows get buried under fresh truth posts and silently dropped.
// Page through a few windows so archive coverage isn't truncated.
const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows
const collected: Awaited<ReturnType<typeof getPosts>> = []
for (let p = 1; p <= MAX_PAGES; p++) {
const batch = await getPosts(500, p).catch(() => null)
if (!batch || batch.length === 0) break
collected.push(...batch)
if (batch.length < 500) break // last page reached
}
return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null
}, },
}) })
+52 -6
View File
@@ -723,6 +723,34 @@ html[data-theme="dark"] .infotip-bubble {
min-height: 66px; min-height: 66px;
} }
/* Hero variant — for a section with a SINGLE headline card (Valuation/AHR999).
The stacked card layout stretched full-width left a huge dead middle; the
hero lays out horizontally: value block left, summary + chips filling the
middle, chart button right. Wraps back to stacked on narrow screens. */
.macro-metric-card.hero {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: clamp(16px, 3vw, 40px);
min-height: 0;
}
.macro-metric-card.hero .macro-hero-body {
flex: 1 1 300px;
min-width: 0;
}
.macro-metric-card.hero .macro-summary {
margin-top: 0;
min-height: 0;
}
.macro-metric-card.hero .macro-thresholds {
margin-top: 10px;
}
.macro-metric-card.hero .macro-actions {
margin-top: 0;
padding-top: 0;
flex-shrink: 0;
}
.macro-thresholds { .macro-thresholds {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1066,22 +1094,24 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
/* Placement variants (default = top) */ /* Placement variants (default = top).
--tip-shift is set by InfoTip.tsx on reveal: it nudges a bubble that would
hang past the viewport edge back on-screen (icons near the screen margin). */
.infotip-top .infotip-bubble { .infotip-top .infotip-bubble {
bottom: calc(100% + 8px); bottom: calc(100% + 8px);
left: 50%; transform: translate(-50%, 2px); left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), 2px);
} }
.infotip-top:hover .infotip-bubble, .infotip-top:hover .infotip-bubble,
.infotip-top:focus-visible .infotip-bubble { .infotip-top:focus-visible .infotip-bubble {
transform: translate(-50%, 0); transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
} }
.infotip-bottom .infotip-bubble { .infotip-bottom .infotip-bubble {
top: calc(100% + 8px); top: calc(100% + 8px);
left: 50%; transform: translate(-50%, -2px); left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), -2px);
} }
.infotip-bottom:hover .infotip-bubble, .infotip-bottom:hover .infotip-bubble,
.infotip-bottom:focus-visible .infotip-bubble { .infotip-bottom:focus-visible .infotip-bubble {
transform: translate(-50%, 0); transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
} }
.infotip-left .infotip-bubble { .infotip-left .infotip-bubble {
right: calc(100% + 8px); right: calc(100% + 8px);
@@ -1562,6 +1592,10 @@ html[data-theme="dark"] .chip.down { color: oklch(80% 0.16 27); }
gap: 20px; gap: 20px;
align-items: start; align-items: start;
} }
/* Grid items default to min-width:auto, so an intrinsically-wide child (the
lightweight-charts canvas, a long URL) blows the column past the viewport
on phones — the whole page then scrolls sideways. Let columns shrink. */
.dash-grid > * { min-width: 0; }
.hero-value { .hero-value {
font-size: 44px; font-size: 44px;
@@ -2308,9 +2342,13 @@ html[data-theme="dark"] .post-row.signal-short {
.src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; } .src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; }
.src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; } .src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; }
.src-ico.external { background: var(--bg-sunk); color: var(--ink-2); } .src-ico.external { background: var(--bg-sunk); color: var(--ink-2); }
/* min-width:0 — without it a long unbreakable URL in the text widens the 1fr
grid column past the card and the right edge (meta tag included) is clipped. */
.post-body { min-width: 0; }
.post-body .meta { .post-body .meta {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
font-size: 12px; font-size: 12px;
@@ -2321,6 +2359,7 @@ html[data-theme="dark"] .post-row.signal-short {
line-height: 1.5; line-height: 1.5;
color: var(--ink); color: var(--ink);
margin: 0 0 10px; margin: 0 0 10px;
overflow-wrap: anywhere;
} }
.post-aside { .post-aside {
display: flex; display: flex;
@@ -2812,8 +2851,15 @@ html[data-theme="dark"] .post-row.signal-short {
CSS-hover-only tooltips require). */ CSS-hover-only tooltips require). */
.infotip:active .infotip-bubble { .infotip:active .infotip-bubble {
opacity: 1; opacity: 1;
transform: none;
} }
/* Keep the viewport-clamp shift on tap — `transform: none` here used to
discard it and re-centre the bubble off-screen. */
.infotip-top:active .infotip-bubble,
.infotip-bottom:active .infotip-bubble {
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
}
.infotip-left:active .infotip-bubble { transform: translate(0, -50%); }
.infotip-right:active .infotip-bubble { transform: translate(0, -50%); }
.macro-action-label { .macro-action-label {
font-size: 12px; font-size: 12px;
+12 -12
View File
@@ -230,7 +230,8 @@ function DigestTickerChip({
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`, border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none', boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
cursor: 'pointer', textAlign: 'left', cursor: 'pointer', textAlign: 'left',
minHeight: 108, // No fixed minHeight — content is 3 short lines; 108px left the bottom
// ~40% of every chip empty (worst on phones, 2 chips per row).
width: '100%', width: '100%',
transform: active ? 'translateY(-2px)' : 'none', transform: active ? 'translateY(-2px)' : 'none',
opacity: hasActive && !active ? 0.6 : 1, opacity: hasActive && !active ? 0.6 : 1,
@@ -479,15 +480,12 @@ function WalletCheckWidget({
{row.verdict.label} {row.verdict.label}
</span> </span>
</div> </div>
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}> {/* Speakers + conviction dropped — the digest chip for the same
ticker directly above already shows the handles and max
conviction %. This column keeps only the one-line talk summary. */}
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
{row.talkLine} {row.talkLine}
</div> </div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.speakers}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
{row.conviction}
</div>
</div> </div>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
@@ -540,9 +538,9 @@ function WalletCheckWidget({
} }
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) { function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
if (!tickers.length) { // No tickers → render nothing. A lone "—" dangled under every card whose
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}></span> // post had no extracted assets (most of the feed).
} if (!tickers.length) return null
return ( return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tickers.map((t, i) => ( {tickers.map((t, i) => (
@@ -811,7 +809,9 @@ export default function KolPage({
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">{'KOL Signals'}</h1> <h1 className="page-title">{'KOL Signals'}</h1>
<PageHint count={`${serverTotal} posts`}> {/* No count slot — the digest meta line ("X posts · Y assets") and
the feed pagination already show the totals. */}
<PageHint>
Arthur Hayes, Delphi, Bankless, and 22 more their public calls vs what their wallets actually do. Arthur Hayes, Delphi, Bankless, and 22 more their public calls vs what their wallets actually do.
</PageHint> </PageHint>
</div> </div>
+12 -90
View File
@@ -9,7 +9,6 @@ import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards' import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl' import SystemControl from '@/components/signals/SystemControl'
import InfoTip from '@/components/ui/InfoTip' import InfoTip from '@/components/ui/InfoTip'
import SignalMonitor from '@/components/dashboard/SignalMonitor'
// MacroPanel is 631 lines with heavy indicator math — split it out. // MacroPanel is 631 lines with heavy indicator math — split it out.
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), { const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
@@ -84,18 +83,9 @@ export default function MacroVibesPage({
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> {/* No "You open · bot manages" badge — the SystemControl strip right
below says the same thing verbatim ("You open · bot manages exit"). */}
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1> <h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'var(--bg-sunk)',
color: 'var(--ink-3)', border: '1px solid var(--line)',
letterSpacing: '0.04em',
}}>
{isZh ? '手动开仓 · Bot 托管' : 'You open · bot manages'}
</span>
</div>
</div> </div>
<span className="chip"><span className="live-dot" />Live</span> <span className="chip"><span className="live-dot" />Live</span>
</div> </div>
@@ -133,14 +123,17 @@ export default function MacroVibesPage({
users are reasoning about the broader risk regime. The Funding tab users are reasoning about the broader risk regime. The Funding tab
has its own live funding panel below. */} has its own live funding panel below. */}
{tab === 'bottom' && <MacroPanel />} {tab === 'bottom' && <MacroPanel />}
{/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the
backend scanner is disabled (services/funding_signal.py _enabled=False,
operator-only toggle), so the panel only ever showed "Paused / No
signals yet" — and breakout scanning is unrelated to funding reversal
anyway. Component kept at components/dashboard/SignalMonitor.tsx;
remount here if the scanner is ever re-enabled. */}
{tab === 'funding' && ( {tab === 'funding' && (
<>
<FundingPanel <FundingPanel
isZh={isZh} isZh={isZh}
initialSnapshot={initialFundingSnapshot} initialSnapshot={initialFundingSnapshot}
/> />
<SignalMonitor />
</>
)} )}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
@@ -317,14 +310,10 @@ function FundingPanel({
)} )}
</div> </div>
{snap.history && snap.history.length > 1 && ( {/* 7-day sparkline removed: the y-range was forced to include the ±threshold
<FundingSparkline lines, so in normal regimes the curve rendered as a flat line on the zero
history={snap.history} axis with ~80% of the plot being empty danger-zone shading. The stat boxes
cadenceHours={snap.cadence_hours ?? 8} above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
extremeCumPct={thr}
isZh={isZh}
/>
)}
</div> </div>
) )
} }
@@ -352,73 +341,6 @@ function StatBox({ label, value, color, sub, hint }: {
) )
} }
// Tiny inline SVG sparkline of the last 7 days of funding cycles.
// `extremeCumPct` is the 30d cumulative threshold (e.g. 3.0). To project it as
// a per-cycle reference line we divide by the number of cycles in 30 days at
// the venue's cadence (24h × 30d / cadence_h). This is the per-cycle rate that
// — sustained for 30 days — would hit the extreme bucket.
function FundingSparkline({ history, cadenceHours, extremeCumPct, isZh }: {
history: { t: number; rate_pct: number }[]
cadenceHours: number
extremeCumPct: number
isZh: boolean
}) {
const W = 600, H = 90, PAD = 6
const cyclesIn30d = Math.max(1, (30 * 24) / Math.max(cadenceHours, 0.5))
const perCycleThr = extremeCumPct / cyclesIn30d // e.g. 3% / 90 ≈ 0.033%
const rates = history.map(h => h.rate_pct)
// Ensure threshold lines are always visible in the y-range
const minR = Math.min(...rates, -perCycleThr * 1.2)
const maxR = Math.max(...rates, perCycleThr * 1.2)
const span = maxR - minR || 1
const x = (i: number) => PAD + (i / (history.length - 1)) * (W - 2 * PAD)
const y = (r: number) => PAD + (1 - (r - minR) / span) * (H - 2 * PAD)
const zeroY = y(0)
const posThrY = y(perCycleThr)
const negThrY = y(-perCycleThr)
const path = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(h.rate_pct)}`).join(' ')
const last = history[history.length - 1]
// Color the dot by whether the latest cycle is inside the danger band
const inDanger = Math.abs(last.rate_pct) >= perCycleThr
const dotColor = inDanger
? (last.rate_pct > 0 ? 'var(--down)' : 'var(--up)')
: 'var(--amber, #f59e0b)'
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>{isZh ? '7 日资金费率(单周期 %' : '7-day funding (per-cycle %)'}</span>
<span>{isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: H, display: 'block' }}>
{/* danger zones — shaded above +thr and below -thr */}
<rect x={PAD} y={PAD} width={W - 2 * PAD} height={Math.max(0, posThrY - PAD)}
fill="var(--down)" opacity={0.07} />
<rect x={PAD} y={negThrY} width={W - 2 * PAD} height={Math.max(0, H - PAD - negThrY)}
fill="var(--up)" opacity={0.07} />
{/* zero line */}
<line x1={PAD} x2={W - PAD} y1={zeroY} y2={zeroY}
stroke="var(--line-2, var(--line))" strokeWidth={1} />
{/* positive / negative threshold lines */}
<line x1={PAD} x2={W - PAD} y1={posThrY} y2={posThrY}
stroke="var(--down)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
<line x1={PAD} x2={W - PAD} y1={negThrY} y2={negThrY}
stroke="var(--up)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
{/* funding curve */}
<path d={path} fill="none" stroke="var(--amber, #f59e0b)" strokeWidth={1.5} />
{/* current value dot */}
<circle cx={x(history.length - 1)} cy={y(last.rate_pct)} r={3.5} fill={dotColor} />
</svg>
</div>
)
}
function BtcSkeleton() { function BtcSkeleton() {
return ( return (
<div className="post-stream" style={{ marginTop: 8 }}> <div className="post-stream" style={{ marginTop: 8 }}>
+17 -1
View File
@@ -151,12 +151,26 @@ export default function TradesPageClient() {
<div className="page-head"> <div className="page-head">
<div> <div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1> <h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
{/* Says what the page is, not where things sit — the section cards
below are self-labelled ("Open positions", KPI row, table). */}
<PageHint> <PageHint>
Open positions above · closed trade history with realized P&amp;L below. What the bot holds right now, and every trade it has closed.
</PageHint> </PageHint>
</div> </div>
</div> </div>
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
dashboard. Show one connect prompt instead and skip the data UI. */}
{mounted && !isConnected ? (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
Positions and trade history are wallet-bound and private.
Use the Connect wallet button in the top-right corner.
</div>
</div>
) : (
<>
{needsSetup && ( {needsSetup && (
<div className="card" style={{ <div className="card" style={{
padding: '14px 18px', marginBottom: 16, padding: '14px 18px', marginBottom: 16,
@@ -210,6 +224,8 @@ export default function TradesPageClient() {
)} )}
<TradeTable trades={trades} loading={loading} locked={needsUnlock} /> <TradeTable trades={trades} loading={loading} locked={needsUnlock} />
</>
)}
</div> </div>
) )
} }
+5 -17
View File
@@ -170,24 +170,12 @@ export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageP
<div className="page"> <div className="page">
<div className="page-head"> <div className="page-head">
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}> {/* No "⚡ Auto-trade" badge — the SystemControl strip right below
always shows the Auto-Trade feature + its live state. */}
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1> <h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
<span style={{ {/* No count slot — the filter tabs right below already show the
display: 'inline-flex', alignItems: 'center', gap: 4, per-filter numbers (All / Actionable / Buy / Short). */}
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700, <PageHint>
background: 'color-mix(in oklab, var(--up) 12%, transparent)',
color: 'var(--up)', border: '1px solid color-mix(in oklab, var(--up) 25%, transparent)',
letterSpacing: '0.04em',
}}>
Auto-trade
</span>
</div>
<PageHint count={
sigFilter === 'buy' ? `${counts.buy} buy signals`
: sigFilter === 'short' ? `${counts.short} short signals`
: sigFilter === 'actionable' ? `${counts.actionable} actionable signals`
: `${counts.actionable} actionable / ${counts.all} posts`
}>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold. Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold.
</PageHint> </PageHint>
</div> </div>
+38 -11
View File
@@ -128,11 +128,14 @@ function MetricCard({
activeIndex, activeIndex,
chartHref, chartHref,
chartLabel = 'CoinGlass', chartLabel = 'CoinGlass',
hero = false,
}: { }: {
rank: number rank: number
label: string label: string
value: string value: string
tone?: Tone tone?: Tone
/** What the indicator IS, one sentence. Do NOT enumerate threshold bands
* here — the chips below each card already list them. */
hint: string hint: string
summary: string summary: string
thresholds: Array<{ label: string; tone?: Tone }> thresholds: Array<{ label: string; tone?: Tone }>
@@ -143,9 +146,11 @@ function MetricCard({
* the dual "Data source + Chart" pattern confused users about which to click. */ * the dual "Data source + Chart" pattern confused users about which to click. */
chartHref: string chartHref: string
chartLabel?: 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
}) { }) {
return ( const head = (
<article className={`macro-metric-card tone-${tone}`}>
<div className="macro-metric-head"> <div className="macro-metric-head">
<div className="macro-metric-title"> <div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span> <span className="macro-rank">{String(rank).padStart(2, '0')}</span>
@@ -154,9 +159,8 @@ function MetricCard({
</div> </div>
<div className="macro-metric-value">{value}</div> <div className="macro-metric-value">{value}</div>
</div> </div>
)
<div className="macro-summary">{summary}</div> const chips = (
<div className="macro-thresholds"> <div className="macro-thresholds">
{thresholds.map((item, i) => ( {thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}> <ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
@@ -164,10 +168,32 @@ function MetricCard({
</ThresholdChip> </ThresholdChip>
))} ))}
</div> </div>
)
const action = (
<div className="macro-actions"> <div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton> <SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div> </div>
)
if (hero) {
return (
<article className={`macro-metric-card tone-${tone} hero`}>
{head}
<div className="macro-hero-body">
<div className="macro-summary">{summary}</div>
{chips}
</div>
{action}
</article>
)
}
return (
<article className={`macro-metric-card tone-${tone}`}>
{head}
<div className="macro-summary">{summary}</div>
{chips}
{action}
</article> </article>
) )
} }
@@ -402,11 +428,12 @@ export default function MacroPanel() {
<Section title="Valuation"> <Section title="Valuation">
<div className="macro-grid one"> <div className="macro-grid one">
<MetricCard <MetricCard
hero
rank={1} rank={1}
label="AHR999" label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'} value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)} tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive." hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. Lower = cheaper."
summary={ahrSay} summary={ahrSay}
activeIndex={ahrActive} activeIndex={ahrActive}
thresholds={[ thresholds={[
@@ -457,7 +484,7 @@ export default function MacroPanel() {
label="Altcoin Season" label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'} value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)} tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason." hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). Higher = broader alt strength."
summary={altSay} summary={altSay}
activeIndex={altActive} activeIndex={altActive}
thresholds={[ thresholds={[
@@ -474,7 +501,7 @@ export default function MacroPanel() {
label="BTC Dominance" label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)} value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)} tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 4555% = balanced, 5565% = BTC-led / defensive, > 65% = flight to safety." hint="BTC's share of total crypto market cap. High dominance = defensive, capital hiding in BTC; low = alt rotation."
summary={domSay} summary={domSay}
activeIndex={domActive} activeIndex={domActive}
thresholds={[ thresholds={[
@@ -491,7 +518,7 @@ export default function MacroPanel() {
label="ETH / BTC" label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)} value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)} tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.0250.04 = range, > 0.04 = ETH and alts gaining leadership." hint="Relative strength of ETH vs BTC — a proxy for broader crypto risk appetite."
summary={ebrSay} summary={ebrSay}
activeIndex={ebrActive} activeIndex={ebrActive}
thresholds={[ thresholds={[
@@ -547,7 +574,7 @@ export default function MacroPanel() {
label="ETF Net Flow (1d)" label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)} value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)} tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, $50M to +$50M = noise, < $200M = heavy outflow." hint="Yesterday's total net flow into/out of US spot BTC ETFs — a direct read on institutional demand."
summary={etfSay} summary={etfSay}
activeIndex={etfActive} activeIndex={etfActive}
thresholds={[ thresholds={[
+50 -8
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import type { TrumpPost, Candle } from '@/types' import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard' import { useDashboardStore } from '@/store/dashboard'
@@ -33,6 +33,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms). // subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
const repositionMacroRef = useRef<(() => void) | null>(null) const repositionMacroRef = useRef<(() => void) | null>(null)
const fittedRef = useRef(false) 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) const postsRef = useRef(posts)
postsRef.current = posts postsRef.current = posts
const selectedPostIdRef = useRef(externalSelectedId) const selectedPostIdRef = useRef(externalSelectedId)
@@ -111,7 +121,11 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const chart = createChart(containerRef.current, { const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth, width: containerRef.current.clientWidth,
height: 360, // 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: { layout: {
background: { color: colors.background }, background: { color: colors.background },
textColor: colors.textColor, textColor: colors.textColor,
@@ -145,6 +159,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
seriesRef.current = series 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 // Re-project macro signal time → pixel X on every visible-range
// change (pan / zoom / data update / resize). Without this the line // change (pan / zoom / data update / resize). Without this the line
// is set ONCE in the data effect and then stays stuck at a fixed // is set ONCE in the data effect and then stays stuck at a fixed
@@ -226,7 +250,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
ro = new ResizeObserver(() => { ro = new ResizeObserver(() => {
if (containerRef.current && !destroyed) { if (containerRef.current && !destroyed) {
chart.applyOptions({ width: containerRef.current.clientWidth }) chart.applyOptions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight || 360,
})
// Width change → new pixel coordinates → re-project the macro // Width change → new pixel coordinates → re-project the macro
// marker too, otherwise it'd drift relative to the candles on // marker too, otherwise it'd drift relative to the candles on
// window resize / sidebar toggle. // window resize / sidebar toggle.
@@ -269,6 +296,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
ro?.disconnect() ro?.disconnect()
themeObserver?.disconnect() themeObserver?.disconnect()
fittedRef.current = false fittedRef.current = false
dataLoadedRef.current = false
setChartReady(false)
if (chartRef.current) { if (chartRef.current) {
// @ts-expect-error lightweight-charts type // @ts-expect-error lightweight-charts type
chartRef.current.remove() chartRef.current.remove()
@@ -291,7 +320,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
useEffect(() => { useEffect(() => {
const series = seriesRef.current const series = seriesRef.current
const chart = chartRef.current const chart = chartRef.current
if (!series || !chart || candles.length === 0) return if (!series || !chart || candles.length === 0) {
dataLoadedRef.current = false
return
}
const sorted = [...candles].sort((a, b) => a.time - b.time) const sorted = [...candles].sort((a, b) => a.time - b.time)
@@ -303,6 +335,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
low: c.low, low: c.low,
close: c.close, close: c.close,
}))) })))
dataLoadedRef.current = true
const minTime = sorted[0].time const minTime = sorted[0].time
const maxTime = sorted[sorted.length - 1].time const maxTime = sorted[sorted.length - 1].time
@@ -313,6 +346,13 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
return t >= minTime && t <= maxTime return t >= minTime && t <= maxTime
}) })
// Markers show actionable signals only. Hold/filtered posts vastly
// outnumber signals and their gray dots pile into unreadable clusters;
// they stay browsable in the Recent-signals list instead.
const markerPosts = visible.filter(
(p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell',
)
const bucketByTf: Record<string, number> = { const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800, '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
} }
@@ -320,7 +360,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>() const bucketMap = new Map<number, typeof visible>()
for (const p of visible) { for (const p of markerPosts) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000) const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, []) if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
@@ -415,7 +455,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
chart.timeScale().fitContent() chart.timeScale().fitContent()
fittedRef.current = true fittedRef.current = true
} }
}, [candles, posts, externalSelectedId, asset]) }, [candles, posts, externalSelectedId, asset, chartReady])
useEffect(() => { fittedRef.current = false }, [timeframe, asset]) useEffect(() => { fittedRef.current = false }, [timeframe, asset])
@@ -426,7 +466,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// the live tick exceeds them. // the live tick exceeds them.
useEffect(() => { useEffect(() => {
const series = seriesRef.current as any const series = seriesRef.current as any
if (!series) return // 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] const live = livePrices[asset]
if (live == null || !candles.length) return if (live == null || !candles.length) return
const last = candles[candles.length - 1] const last = candles[candles.length - 1]
@@ -442,7 +484,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
return ( return (
<div <div
ref={containerRef} ref={containerRef}
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }} style={{ width: '100%', height: '100%', borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
/> />
) )
} }
+12 -19
View File
@@ -149,7 +149,6 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
if (!mounted) return null if (!mounted) return null
const settingsHref = `/${locale}/settings#bot-config` const settingsHref = `/${locale}/settings#bot-config`
const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes'
// Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a // Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a
// trade. bot_engine.process_post early-returns for sys2 sources, so the // trade. bot_engine.process_post early-returns for sys2 sources, so the
@@ -184,7 +183,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
opacity: 0.8, opacity: 0.8,
}} }}
> >
{settingsLabel} settings Settings
</Link> </Link>
</div> </div>
) )
@@ -211,8 +210,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
width: 6, height: 6, borderRadius: '50%', width: 6, height: 6, borderRadius: '50%',
background: 'var(--ink-4)', flexShrink: 0, background: 'var(--ink-4)', flexShrink: 0,
}} /> }} />
{/* No "{s.name}" prefix — the page title right above already names
the system; repeating it here read "Trump Signal" twice per screen. */}
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}> <span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
{s.name} Auto-Trade Auto-Trade
</span> </span>
<span>·</span> <span>·</span>
<span style={{ color: 'var(--ink-4)' }}> <span style={{ color: 'var(--ink-4)' }}>
@@ -236,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)', boxShadow: 'var(--shadow-1)',
}} }}
> >
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}> <span>Settings</span>
Settings
</span>
<span>{settingsLabel}</span>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</Link> </Link>
</div> </div>
@@ -297,23 +295,21 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
{/* Header */} {/* Header */}
<div style={{ padding: '14px 16px', background: s.soft, <div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}> borderLeft: `4px solid ${s.accent}` }}>
{/* Header is just "Auto-Trade" — the page title already names the
system, and the switch row below repeats "Auto-Trade" otherwise. */}
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}> <div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.name} {isZh ? '控制面板' : 'Auto-Trade'} {isZh ? '控制面板' : 'Auto-Trade'}
</div> </div>
</div> </div>
{/* Master Auto-Trade switch */} {/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}> <div style={{ padding: '16px' }}>
<div style={ROW}> <div style={ROW}>
<div> {/* The card header above already says "Auto-Trade" — this row only
<div style={{ fontSize: 14, fontWeight: 700 }}> carries the ON/OFF legend, not a second title. */}
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600, <div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 事件交易' : '· event-driven'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'} {isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div> </div>
</div>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill> <Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
<Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill> <Pill active={!autoOn} onClick={() => flipAuto(false)} tone="idle">OFF</Pill>
@@ -377,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)', boxShadow: 'var(--shadow-1)',
}} }}
> >
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}> <span>Settings</span>
Settings
</span>
<span>{settingsLabel}</span>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</Link> </Link>
</div> </div>
+3 -3
View File
@@ -835,7 +835,7 @@ export default function BotConfigPanel() {
<div className="form-row"> <div className="form-row">
<div className="form-row-label"> <div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span> Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot locks in profit when the position gains this much. Required.</span> <span className="hint">Bot locks in profit when the position gains this much.</span>
</div> </div>
<div className="form-row-control"> <div className="form-row-control">
<div className="num-field"> <div className="num-field">
@@ -851,7 +851,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}> <div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label"> <div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span> Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span> <span className="hint">Bot cuts the loss when the position falls this much.</span>
</div> </div>
<div className="form-row-control"> <div className="form-row-control">
<div className="num-field"> <div className="num-field">
@@ -998,7 +998,7 @@ export default function BotConfigPanel() {
<div className="form-row"> <div className="form-row">
<div className="form-row-label"> <div className="form-row-label">
Daily trading cap Daily trading cap
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span> <span className="hint">Bot stops opening new trades once it has spent this amount in a day.</span>
</div> </div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}> <div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} /> <Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
+18 -14
View File
@@ -288,14 +288,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
<table className="table"> <table className="table">
<thead> <thead>
<tr> <tr>
{/* P&L right after Side — it's the column this page exists for.
As the last of 8 columns it sat off-screen on phones (the
table scrolls inside its card) until the user swiped past
entry/exit/hold/trigger to find it. */}
<th>{isZh ? '来源' : 'Source'}</th> <th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th> <th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th> <th>{isZh ? '方向' : 'Side'}</th>
<th>P&amp;L</th>
<th>{isZh ? '开仓' : 'Entry'}</th> <th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th> <th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th> <th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th> <th>{isZh ? '触发信号' : 'What triggered it'}</th>
<th>P&amp;L</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -346,20 +350,8 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')} {t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span> </span>
</td> </td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td> <td>
<div className="stack" style={{ alignItems: 'flex-end' }}> <div className="stack" style={{ alignItems: 'flex-start' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? ( {t.pnl_usd === null || t.pnl_usd === undefined ? (
<span <span
style={{ fontSize: 12, color: 'var(--ink-4)' }} style={{ fontSize: 12, color: 'var(--ink-4)' }}
@@ -379,6 +371,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
)} )}
</div> </div>
</td> </td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
</tr> </tr>
) )
})} })}
+30 -4
View File
@@ -13,10 +13,12 @@
* If you need a paragraph, you're explaining the wrong thing. * If you need a paragraph, you're explaining the wrong thing.
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It * * The icon is intentionally subtle (12 px circle, ink-4 colour). It
* should disappear visually until the user actively scans for help. * should disappear visually until the user actively scans for help.
* * The tooltip uses CSS hover only no JS positioning, no portals. * * The tooltip uses CSS hover positioning no portals. The only JS is
* Trade-off: it's clipped by the nearest `overflow:hidden` ancestor. * an on-reveal measurement that clamps the bubble inside the viewport
* Pages that need tooltips inside a `<table>` cell should add * (a centred bubble on an icon near the screen edge used to hang
* `overflow: visible` to that row/td. * off-screen, worst on phones). Trade-off: it's still clipped by the
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
* a `<table>` cell should add `overflow: visible` to that row/td.
* *
* Usage: * Usage:
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span> * <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
@@ -27,6 +29,7 @@
* /> * />
*/ */
import { useRef } from 'react'
import type { CSSProperties } from 'react' import type { CSSProperties } from 'react'
type Placement = 'top' | 'bottom' | 'left' | 'right' type Placement = 'top' | 'bottom' | 'left' | 'right'
@@ -46,8 +49,28 @@ export default function InfoTip({
width = 240, width = 240,
iconStyle, iconStyle,
}: Props) { }: Props) {
const rootRef = useRef<HTMLSpanElement>(null)
// Clamp the bubble inside the viewport when it's revealed. The bubble is
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
// Measured at shift=0, then the needed horizontal offset is written to the
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
function clampIntoViewport() {
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
if (!bubble) return
bubble.style.setProperty('--tip-shift', '0px')
const r = bubble.getBoundingClientRect()
const vw = document.documentElement.clientWidth
const margin = 8
let shift = 0
if (r.left < margin) shift = margin - r.left
else if (r.right > vw - margin) shift = (vw - margin) - r.right
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
}
return ( return (
<span <span
ref={rootRef}
className={`infotip infotip-${placement}`} className={`infotip infotip-${placement}`}
// role=button is a slight overstatement but it lets screen-reader users // role=button is a slight overstatement but it lets screen-reader users
// know there's a thing to tab to. The actual content is in data-tip and // know there's a thing to tab to. The actual content is in data-tip and
@@ -56,6 +79,9 @@ export default function InfoTip({
tabIndex={0} tabIndex={0}
aria-label={text} aria-label={text}
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }} style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
onMouseEnter={clampIntoViewport}
onFocus={clampIntoViewport}
onTouchStart={clampIntoViewport}
> >
<span aria-hidden="true" className="infotip-icon">?</span> <span aria-hidden="true" className="infotip-icon">?</span>
<span className="infotip-bubble" role="tooltip">{text}</span> <span className="infotip-bubble" role="tooltip">{text}</span>
+23 -18
View File
@@ -36,6 +36,19 @@ async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
/** Signed-read credentials as headers (C3): keeps EIP-191 signatures out of
* URLs, access logs, and browser history. Backend still accepts the legacy
* ?ts=&sig= query params for old clients. */
function signedReadInit(env: SignedEnvelope): RequestInit {
return {
headers: {
'Content-Type': 'application/json',
'X-Sig-Ts': String(env.timestamp),
'X-Sig-Sig': env.signature,
},
}
}
export interface PostListResponse { export interface PostListResponse {
items: TrumpPost[] items: TrumpPost[]
total: number total: number
@@ -104,22 +117,16 @@ export async function getTrades(
limit = 20, limit = 20,
page = 1, page = 1,
): Promise<BotTrade[]> { ): Promise<BotTrade[]> {
const qs = [ const qs = `wallet=${wallet.toLowerCase()}&limit=${limit}&page=${page}`
`wallet=${wallet.toLowerCase()}`, return fetchJson<BotTrade[]>(`/trades?${qs}`, signedReadInit(env))
`ts=${env.timestamp}`,
`sig=${encodeURIComponent(env.signature)}`,
`limit=${limit}`,
`page=${page}`,
].join('&')
return fetchJson<BotTrade[]>(`/trades?${qs}`)
} }
export async function getPerformance( export async function getPerformance(
wallet: string, wallet: string,
env: SignedEnvelope, env: SignedEnvelope,
): Promise<BotPerformance> { ): Promise<BotPerformance> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<BotPerformance>(`/performance?${qs}`) return fetchJson<BotPerformance>(`/performance?${qs}`, signedReadInit(env))
} }
export async function subscribe( export async function subscribe(
@@ -272,16 +279,16 @@ export async function getOpenPositions(
wallet: string, wallet: string,
env: SignedEnvelope, env: SignedEnvelope,
): Promise<OpenPositionsResponse> { ): Promise<OpenPositionsResponse> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`) return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`, signedReadInit(env))
} }
export async function getTodayStats( export async function getTodayStats(
wallet: string, wallet: string,
env: SignedEnvelope, env: SignedEnvelope,
): Promise<TodayStats> { ): Promise<TodayStats> {
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` const qs = `wallet=${wallet.toLowerCase()}`
return fetchJson<TodayStats>(`/positions/today?${qs}`) return fetchJson<TodayStats>(`/positions/today?${qs}`, signedReadInit(env))
} }
// ─── Scanner control (module 2) ───────────────────────────────────────────── // ─── Scanner control (module 2) ─────────────────────────────────────────────
@@ -378,8 +385,7 @@ export async function getUser(
wallet: string, wallet: string,
env: SignedEnvelope, env: SignedEnvelope,
): Promise<UserData> { ): Promise<UserData> {
const qs = `ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}` return fetchJson<UserData>(`/user/${wallet.toLowerCase()}`, signedReadInit(env))
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`)
} }
export interface WindowAccuracy { export interface WindowAccuracy {
@@ -586,8 +592,7 @@ export async function getTelegramStatus(wallet: string, env?: SignedEnvelope): P
// Pass signature when available so the backend returns full binding details. // Pass signature when available so the backend returns full binding details.
// Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation. // Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation.
if (env) { if (env) {
const qs = new URLSearchParams({ timestamp: String(env.timestamp), signature: env.signature }) return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`, signedReadInit(env))
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status?${qs}`)
} }
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`) return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
} }
+16 -5
View File
@@ -18,6 +18,11 @@ interface CacheEntry<T> {
data: T data: T
ts: number ts: number
revalidating: boolean revalidating: boolean
/** onUpdate callbacks of every caller that hit this key while a background
* revalidation was in flight (M9 fix). All are notified when the fetch
* resolves previously only the caller that STARTED the revalidation got
* fresh data; everyone else silently kept the stale copy. */
subscribers: Array<(fresh: T) => void>
} }
const store = new Map<string, CacheEntry<unknown>>() const store = new Map<string, CacheEntry<unknown>>()
@@ -37,16 +42,22 @@ export async function swrFetch<T>(
// Still fresh — return immediately. // Still fresh — return immediately.
return hit.data return hit.data
} }
// Stale — return immediately AND revalidate in background. // Stale — return immediately AND revalidate in background. Every caller's
// onUpdate joins the subscriber list; the in-flight fetch notifies all.
if (onUpdate) hit.subscribers.push(onUpdate)
if (!hit.revalidating) { if (!hit.revalidating) {
hit.revalidating = true hit.revalidating = true
fetcher() fetcher()
.then(fresh => { .then(fresh => {
store.set(key, { data: fresh, ts: Date.now(), revalidating: false }) const subs = hit.subscribers
onUpdate?.(fresh) store.set(key, { data: fresh, ts: Date.now(), revalidating: false, subscribers: [] })
for (const notify of subs) {
try { notify(fresh) } catch { /* one bad subscriber must not starve the rest */ }
}
}) })
.catch(() => { .catch(() => {
if (hit) hit.revalidating = false hit.revalidating = false
hit.subscribers = []
}) })
} }
return hit.data return hit.data
@@ -54,7 +65,7 @@ export async function swrFetch<T>(
// Cache miss — must wait for the first fetch. // Cache miss — must wait for the first fetch.
const data = await fetcher() const data = await fetcher()
store.set(key, { data, ts: Date.now(), revalidating: false }) store.set(key, { data, ts: Date.now(), revalidating: false, subscribers: [] })
return data return data
} }