Compare commits
10 Commits
fe88acb5a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8534d90589 | |||
| 4c3c8c6f87 | |||
| 9e0f6554cb | |||
| ec8e9de5b1 | |||
| 33331efaee | |||
| 18cb567ee8 | |||
| ef98565061 | |||
| 90cccd422b | |||
| c8dd7176fa | |||
| 3f35a3fba2 |
+2
-2
@@ -2,10 +2,10 @@
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "trumpsignal",
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"cwd": "/Users/k/Public/Claude/trumpsignal",
|
||||
"cwd": "/Users/k/Public/trumpsignal/frontend",
|
||||
"port": 3001
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,3 +14,9 @@ NEXT_PUBLIC_WS_URL=wss://api.yourdomain.com
|
||||
# Dev: http://localhost:3001
|
||||
# Production: https://yourdomain.com
|
||||
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
|
||||
|
||||
# WalletConnect v2 project ID — enables QR-code pairing (desktop → mobile wallet)
|
||||
# and all WalletConnect-compatible mobile wallets (Trust, Rainbow, etc.)
|
||||
# Get a FREE project ID at https://cloud.walletconnect.com (takes ~2 min)
|
||||
# Leave empty: injected-only mode (browser extension wallets on desktop only)
|
||||
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
|
||||
|
||||
@@ -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-01–06, 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:00–02: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.
|
||||
@@ -15,8 +15,8 @@ This is the AI-readable entry doc. Read this first on entering the repo.
|
||||
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 **`/Users/k/Public/Claude/
|
||||
backend`** repo. See its CLAUDE.md.
|
||||
Hyperliquid integration lives in the sibling **`../backend`**
|
||||
repo. See its CLAUDE.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.
|
||||
@@ -51,7 +51,7 @@ This is the AI-readable entry doc. Read this first on entering the repo.
|
||||
```
|
||||
app/
|
||||
├── layout.tsx Root layout: JSON-LD schema.org, fonts, meta
|
||||
├── page.tsx / (root) → redirects to /en
|
||||
├── 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)
|
||||
@@ -89,30 +89,36 @@ app/[locale]/
|
||||
components/
|
||||
├── nav/Navbar.tsx Top nav + tabs (Trump | Macro Vibes | KOL ...)
|
||||
├── signals/
|
||||
│ ├── SignalMonitor.tsx Live signal stream (WS-driven)
|
||||
│ ├── SourceChips.tsx Filter chips per source
|
||||
│ ├── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch
|
||||
│ └── ConfirmCloseTrade.tsx Modal for manual close
|
||||
│ └── SystemControl.tsx Sys1/sys2 toggle widgets, Auto-Trade switch
|
||||
├── dashboard/
|
||||
│ ├── PostCards.tsx Trump post cards
|
||||
│ ├── SignalMonitor.tsx (older — being consolidated)
|
||||
│ └── BtcReversalAlert.tsx Pinned alert when sys2 fires
|
||||
│ ├── 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
|
||||
│ └── TradeCard.tsx Single trade row w/ grow toggle + close button
|
||||
├── kol/KolDigest.tsx Daily KOL summary widget
|
||||
│ └── 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
|
||||
│ └── TelegramCard.tsx Settings — connect via 6-char code
|
||||
├── wallet/
|
||||
│ └── SignConfirmSheet.tsx EIP-191 signed request preview
|
||||
├── nav/WalletConnect.tsx Wagmi-style wallet connect (MetaMask etc.)
|
||||
├── seo/Breadcrumbs.tsx Structured breadcrumb nav for SEO
|
||||
├── ui/
|
||||
│ ├── InfoTip.tsx CSS-only tooltip with `?` icon
|
||||
│ ├── PageHint.tsx Strong page subtitle (replaces page-sub)
|
||||
│ ├── Toast.tsx
|
||||
│ └── Modal.tsx
|
||||
└── ws/WsProvider.tsx WebSocket singleton context provider
|
||||
│ ├── 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)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -240,6 +246,70 @@ Tokens in `app/[locale]/globals.css`:
|
||||
`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:00–02: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
|
||||
@@ -303,6 +373,6 @@ in Vercel dashboard:
|
||||
|
||||
## Sibling repo
|
||||
|
||||
- **`/Users/k/Public/Claude/backend`** — Python/FastAPI backend, includes
|
||||
- **`/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 CLAUDE.md.
|
||||
|
||||
+436
-129
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -9,12 +9,15 @@ import { useAccount } from 'wagmi'
|
||||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, 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 { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import { swrFetch, invalidate as invalidateCache } from '@/lib/cache'
|
||||
import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import OpenPositions from '@/components/positions/OpenPositions'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
import AnimatedNumber from '@/components/ui/AnimatedNumber'
|
||||
|
||||
// Heavy components — lazy-loaded so they don't bloat the initial JS bundle.
|
||||
// ChartPanel pulls in lightweight-charts (~200KB gz); split it out.
|
||||
@@ -58,7 +61,9 @@ function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void })
|
||||
<div className="row gap-s" style={{ marginBottom: 12 }}>
|
||||
<SourceIcon source={post.source} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>@realDonaldTrump</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>
|
||||
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}><TimeAgo iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +91,9 @@ function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void })
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<>
|
||||
<div className="ai-reasoning-label">AI reasoning</div>
|
||||
<div className="ai-reasoning-label">
|
||||
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
|
||||
</div>
|
||||
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
|
||||
{post.ai_reasoning}
|
||||
</div>
|
||||
@@ -147,42 +154,78 @@ function SelectHint() {
|
||||
export default function DashboardClient({ initialPosts }: Props) {
|
||||
const intlLocale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
|
||||
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
|
||||
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
|
||||
const { address, isConnected } = useAccount()
|
||||
const params = useParams()
|
||||
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
|
||||
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 [candles, setCandles] = useState<Candle[]>([])
|
||||
const [hideFiltered, setHideFiltered] = useState(false)
|
||||
const [chartErr, setChartErr] = useState('')
|
||||
const [chartReload, setChartReload] = useState(0)
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
const railRef = useRef<HTMLDivElement>(null)
|
||||
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
|
||||
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
|
||||
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
|
||||
// For 1D: show all posts from selected day
|
||||
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(() => {
|
||||
// B42: cancel the in-flight getUserPublic if the wallet switches again
|
||||
// before it resolves — prevents old wallet's data polluting new wallet's state.
|
||||
let cancelled = false
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
setPerformance(undefined)
|
||||
return
|
||||
return () => { cancelled = true }
|
||||
}
|
||||
// Clear account-scoped bot state immediately when the connected wallet
|
||||
// changes so a previous wallet's status never leaks into the next one.
|
||||
// Clear account-scoped bot state immediately (setWallet already resets these
|
||||
// via store, but DashboardClient may be rendered without a wallet switch —
|
||||
// keep explicit resets here for clarity and safety).
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
// `snapAddr !== address` would be a stale-closure trap: both are the same
|
||||
// closed-over value. Use `cancelled` (set by effect cleanup) as the sole guard.
|
||||
getUserPublic(address.toLowerCase())
|
||||
.then((user) => {
|
||||
if (cancelled) return // effect cleaned up = wallet changed or unmounted
|
||||
setSubscribed(user.active)
|
||||
setHlApiKeySet(user.hl_api_key_set)
|
||||
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setPaperMode(!!user.paper_mode)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
@@ -207,22 +250,94 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected])
|
||||
|
||||
const [freshPostId, setFreshPostId] = useState<number | null>(null)
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
|
||||
onNewPost: (post) => {
|
||||
const p = post as TrumpPost
|
||||
// Dedup: WS may resend a post already in initialPosts or a prior push.
|
||||
setPosts((prev) => prev.some(x => x.id === p.id) ? prev : [p, ...prev].slice(0, 500))
|
||||
setFreshPostId(p.id)
|
||||
// Keep a ref to the timer so we can cancel it if the component unmounts
|
||||
// before it fires (avoids setState-after-unmount warning).
|
||||
const timer = setTimeout(() => setFreshPostId((id) => (id === p.id ? null : id)), 1400)
|
||||
return () => clearTimeout(timer) // returned but not used by usePriceSocket — see L1 note
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setCandles([])
|
||||
setChartErr('')
|
||||
getPrices(asset, timeframe)
|
||||
.then(c => { setCandles(c); setChartErr('') })
|
||||
.catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
|
||||
// isZh intentionally excluded: it is a compile-time constant (always false)
|
||||
// and including it would restart the chart fetch on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const priceKey = `prices-${asset}-${timeframe}`
|
||||
if (chartReload > 0) invalidateCache(priceKey)
|
||||
swrFetch(
|
||||
priceKey,
|
||||
90_000,
|
||||
() => getPrices(asset, timeframe),
|
||||
fresh => { if (!cancelled) { setCandles(fresh); setChartErr('') } },
|
||||
)
|
||||
.then(c => { if (!cancelled) { setCandles(c); setChartErr('') } })
|
||||
.catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') })
|
||||
return () => { cancelled = true }
|
||||
}, [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(() => {
|
||||
let alive = true
|
||||
function load() {
|
||||
@@ -240,12 +355,80 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
return () => { alive = false; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
// 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)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
// KOL ingestion is daily and sparse — a 7d window is frequently empty
|
||||
// (e.g. 7d=0 while 30d=196), which hid the whole sidebar card. Use a 30d
|
||||
// window so the Overview reflects the data that actually exists.
|
||||
swrFetch('kol-divergence-30d', 30 * 60_000, () => getKolDivergence({ days: 30 }), f => { if (alive) setKolDivergences(f.items ?? []) })
|
||||
.then(r => { if (alive) setKolDivergences(r.items ?? []) })
|
||||
.catch(() => {})
|
||||
swrFetch('kol-digest-30d', 30 * 60_000, () => getKolDigest(30), f => { if (alive) setKolDigest(f) })
|
||||
.then(r => { if (alive) setKolDigest(r) })
|
||||
.catch(() => {})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
// 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.
|
||||
// Cap at 8 total so the list doesn't get too long.
|
||||
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4)
|
||||
const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4)
|
||||
// Cap at 8 total so the list doesn't get too long. "Signals only" hides the
|
||||
// hold/filtered (gray) posts entirely — same toggle as the Trump page.
|
||||
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 lastCandle = candles[candles.length - 1]
|
||||
@@ -272,13 +455,17 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
|
||||
: 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 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 trumpActionable = 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 kolMentions = posts.filter(p => (p.source || '') === 'kol').length
|
||||
const actionablePosts = serverCounts?.actionable ?? posts.filter(p => 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 = 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),
|
||||
// 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 winRate = performance?.win_rate ?? 0
|
||||
const netPnl = performance?.net_pnl_usd ?? 0
|
||||
const hasPriceData = candles.length > 0
|
||||
@@ -286,37 +473,44 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
const macroScore = macro?.composite_score ?? null
|
||||
const macroRegime = macro?.regime_label ?? null
|
||||
const macroPct = macroScore == null ? 50 : Math.max(0, Math.min(100, (macroScore + 100) / 2))
|
||||
// Derive tone from the backend's regime_label, NOT a re-thresholded score.
|
||||
// The backend uses ±20 for BULLISH/BEARISH; re-deriving with ±15 here made
|
||||
// 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.
|
||||
const macroTone =
|
||||
macroScore == null ? 'neutral'
|
||||
: macroScore > 15 ? 'bull'
|
||||
: macroScore < -15 ? 'bear'
|
||||
: 'neutral'
|
||||
macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
|
||||
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
|
||||
: 'neutral' // covers NEUTRAL and the null/not-loaded case
|
||||
const macroSummary =
|
||||
macroScore == null ? 'Daily macro composite not loaded yet.'
|
||||
: macroTone === 'bull' ? 'Risk backdrop is supportive. Trend-following setups get more room.'
|
||||
: macroTone === 'bear' ? 'Backdrop is defensive. Preserve size and expect cleaner downside moves.'
|
||||
: 'Backdrop is mixed. Useful for context, not a blind directional trigger.'
|
||||
: macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.'
|
||||
: macroTone === 'bear' ? 'Defensive backdrop — preserve size, downside moves are cleaner.'
|
||||
: 'Mixed backdrop — context only, not a directional trigger.'
|
||||
|
||||
return (
|
||||
<div className="page wide">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
|
||||
<PageHint count={`${actionablePosts} actionable · ${totalPosts} tracked`}>
|
||||
Four signals that move crypto before the crowd sees them — Trump
|
||||
posts, BTC macro bottoms, funding extremes, and what KOLs do vs
|
||||
say. Tracked live, in public, every call timestamped.
|
||||
<PageHint count={actionablePosts > 0 ? `${actionablePosts} actionable · ${totalPosts} tracked` : undefined}>
|
||||
Trump · Macro · KOL divergence — live.
|
||||
</PageHint>
|
||||
</div>
|
||||
<div className="row gap-s">
|
||||
<span className="chip"><span className="live-dot" />Live feed</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open positions — what's on the book right now. Renders only when
|
||||
a subscribed wallet is connected, so guests see the normal feed. */}
|
||||
<OpenPositions />
|
||||
|
||||
|
||||
<div className="overview-shell">
|
||||
<div className="overview-main">
|
||||
<section className="overview-market-card">
|
||||
@@ -324,27 +518,15 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<div>
|
||||
<div className="overview-kicker">Market and macro</div>
|
||||
<div className="overview-headline-row">
|
||||
<div className="hero-value mono" style={{ fontSize: 40 }}>
|
||||
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
</div>
|
||||
<AnimatedNumber
|
||||
className="hero-value mono"
|
||||
style={{ fontSize: 40 }}
|
||||
value={displayPrice}
|
||||
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
/>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
</div>
|
||||
<div className="overview-market-subtitle">BTC spot with live signal context</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 className="overview-market-subtitle">{asset} · live signal context</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,62 +556,137 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-system-strip">
|
||||
<Link href={`/${locale}/trump`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Trump</span>
|
||||
<strong>{trumpActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/macro`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Macro</span>
|
||||
<strong>{macroActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/kol`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">KOL</span>
|
||||
<strong>{kolMentions}</strong>
|
||||
</Link>
|
||||
<div className="overview-system-chip passive">
|
||||
<span className="overview-system-chip-name">Signals today</span>
|
||||
<strong>{signalsToday}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-secondary-grid">
|
||||
<section className="overview-stat-card">
|
||||
<div className="overview-kicker">Execution</div>
|
||||
<div className="overview-stat-value">{actionablePosts}</div>
|
||||
<div className="overview-stat-label">actionable signals tracked in feed</div>
|
||||
</section>
|
||||
<section className="overview-stat-card accent">
|
||||
<div className="overview-kicker">Performance</div>
|
||||
<div className="overview-stat-value">{hasPerformanceData ? `${netPnl >= 0 ? '+$' : '-$'}${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}` : '—'}</div>
|
||||
<div className="overview-stat-label">{hasPerformanceData ? '30d live net P&L (real trades only)' : 'Load settings once to unlock private performance'}</div>
|
||||
</section>
|
||||
{/* ── Unified stats row ─────────────────────────────────────────── */}
|
||||
<div className="overview-stats-bar">
|
||||
{([
|
||||
{ label: 'Trump signals', value: trumpActionable, href: `/${locale}/trump` },
|
||||
{ label: 'Macro signals', value: macroActionable, href: `/${locale}/macro` },
|
||||
{ label: 'KOL divergence', value: kolMentions, href: `/${locale}/kol` },
|
||||
] as const).map(({ label, value, href }) => {
|
||||
const empty = value === 0
|
||||
const inner = (
|
||||
<>
|
||||
<span className="stats-bar-label">{label}</span>
|
||||
<span className="stats-bar-value" style={{ color: empty ? 'var(--ink-4)' : 'var(--ink)' }}>
|
||||
{empty ? '—' : value}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return <Link key={label} href={href} className="stats-bar-item">{inner}</Link>
|
||||
})}
|
||||
<div className="stats-bar-item passive" style={{ borderRight: 'none' }}>
|
||||
<span className="stats-bar-label">My P&L · 30d</span>
|
||||
<span className="stats-bar-value" style={{
|
||||
color: hasPerformanceData ? (netPnl >= 0 ? 'var(--up)' : 'var(--down)') : 'var(--ink-4)',
|
||||
}}>
|
||||
{hasPerformanceData
|
||||
? `${netPnl >= 0 ? '+' : '−'}$${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="overview-side">
|
||||
<section className="overview-side-card">
|
||||
<div className="overview-kicker">Account</div>
|
||||
<div className="overview-account-list">
|
||||
<div className="overview-account-item">
|
||||
<span>Wallet</span>
|
||||
<strong>{isConnected && address ? `${address.slice(0, 6)}…${address.slice(-4)}` : 'Not connected'}</strong>
|
||||
{isConnected ? (
|
||||
<section className="overview-side-card">
|
||||
<div className="overview-kicker">Account</div>
|
||||
<div className="overview-account-list">
|
||||
<div className="overview-account-item">
|
||||
<span>Wallet</span>
|
||||
<strong className="mono">{address ? `${address.slice(0, 6)}…${address.slice(-4)}` : '—'}</strong>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Settings</span>
|
||||
<strong>{hasPerformanceData ? 'Loaded' : 'Not loaded'}</strong>
|
||||
</div>
|
||||
{hasPerformanceData && (
|
||||
<div className="overview-account-item">
|
||||
<span>Win rate</span>
|
||||
<strong>{`${(winRate * 100).toFixed(1)}%`}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Private data</span>
|
||||
<strong>{hasPerformanceData ? 'Unlocked' : 'Locked until Settings load'}</strong>
|
||||
</section>
|
||||
) : (
|
||||
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
|
||||
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
|
||||
{([
|
||||
{ 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', href: `/${locale}/settings` },
|
||||
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access', href: `/${locale}/trades` },
|
||||
] as const).map(({ icon, head, sub, href }) => (
|
||||
<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>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{/* KOL divergence hook — highest-conviction signal type */}
|
||||
{(kolDivergences.length > 0 || (kolDigest && kolDigest.tickers.length > 0)) && (
|
||||
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div className="overview-kicker">KOL intel · 30d</div>
|
||||
<a href={`/${locale}/kol`} style={{ fontSize: 10, color: 'var(--amber-ink)', textDecoration: 'none', fontWeight: 700 }}>
|
||||
Full feed ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Win rate</span>
|
||||
<strong>{hasPerformanceData ? `${(winRate * 100).toFixed(1)}%` : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="overview-side-card compact">
|
||||
<div className="overview-kicker">Chart focus</div>
|
||||
<div className="overview-side-copy">Live chart with signal markers and drill-down on click.</div>
|
||||
</section>
|
||||
|
||||
{/* Latest divergence — the money signal */}
|
||||
{kolDivergences.filter(d => d.signal_type === 'divergence').slice(0, 1).map(d => (
|
||||
<div key={d.id} style={{
|
||||
padding: '9px 11px', borderRadius: 7, marginBottom: 8,
|
||||
background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.25)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 800, color: '#f59e0b', letterSpacing: '0.05em' }}>⚠️ DIVERGENCE</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
|
||||
{/* post_at = when the KOL actually published. created_at
|
||||
is the DB write time, which a backfill/late scan can
|
||||
set to "now", making an old event look fresh. */}
|
||||
{Math.round((Date.now() - new Date(d.post_at).getTime()) / 864e5)}d ago
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600 }}>
|
||||
@{d.handle} said <span style={{ color: d.post_action === 'bullish' || d.post_action === 'buy' ? 'var(--up)' : 'var(--down)' }}>{d.post_action}</span>
|
||||
{' '}on <strong>{d.ticker}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{/* /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>
|
||||
))}
|
||||
|
||||
{/* Top digest tickers */}
|
||||
{kolDigest && kolDigest.tickers.slice(0, 2).map(t => (
|
||||
<div key={t.ticker} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 0', borderBottom: '1px solid var(--line)',
|
||||
}}>
|
||||
<div>
|
||||
<span style={{ fontSize: 12, fontWeight: 700 }}>{t.ticker}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--ink-4)', marginLeft: 6 }}>{t.kol_count} KOLs</span>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 4,
|
||||
background: t.side === 'long' ? 'var(--up-soft)' : t.side === 'short' ? 'rgba(220,38,38,.12)' : 'var(--bg-sunk)',
|
||||
color: t.side === 'long' ? 'var(--up)' : t.side === 'short' ? 'var(--down)' : 'var(--ink-3)',
|
||||
}}>
|
||||
{t.dominant_action.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -437,17 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
{/* Left: Chart + signal stream */}
|
||||
<div className="stack gap-l">
|
||||
<div className="card" style={{ padding: 24 }}>
|
||||
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div className="tiny">Price · {asset}</div>
|
||||
<div className="row gap-m" style={{ marginTop: 6 }}>
|
||||
<div className="hero-value mono" style={{ fontSize: 32 }}>
|
||||
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
</div>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
{/* The big price lives in the market hero above — repeating it here
|
||||
just duplicated the same number 32px tall. The chart's own price
|
||||
axis label shows the live price; this header instead hosts the
|
||||
asset/timeframe controls right next to the chart they drive. */}
|
||||
<div className="row between" style={{ marginBottom: 14, alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div className="tiny">Live chart · {asset} · signal markers</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 className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
|
||||
</div>
|
||||
|
||||
{chartErr && (
|
||||
@@ -459,9 +726,22 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
onClick={() => setChartReload(n => n + 1)}>Retry</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="chart-wrap">
|
||||
<div className="chart-wrap" style={{ position: 'relative' }}>
|
||||
{/* Loading overlay — shown while candles are fetching (candles=[] and no error).
|
||||
Prevents the user from seeing a blank lightweight-charts canvas. */}
|
||||
{!hasPriceData && !chartErr && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, zIndex: 4,
|
||||
background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 10, color: 'var(--ink-3)', fontSize: 13,
|
||||
}}>
|
||||
<span className="live-dot" style={{ background: 'var(--amber)' }} />
|
||||
Loading chart…
|
||||
</div>
|
||||
)}
|
||||
<ChartPanel
|
||||
posts={posts}
|
||||
posts={chartPosts}
|
||||
candles={candles}
|
||||
externalSelectedId={selectedPostId}
|
||||
onSelectPost={(id) => {
|
||||
@@ -476,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="chart-footnote">
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
|
||||
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
|
||||
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
|
||||
<div className="item">
|
||||
Markers
|
||||
<InfoTip
|
||||
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>
|
||||
</div>
|
||||
@@ -488,21 +774,42 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<div>
|
||||
<div className="section-title">
|
||||
<h2>Recent signals</h2>
|
||||
<span className="hint">
|
||||
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
|
||||
{`${totalPosts} tracked`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setHideFiltered(v => !v)}
|
||||
title="Hide hold/filtered (gray) posts and show only buy/short signals"
|
||||
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 className="post-stream">
|
||||
{recentPosts.map(p => (
|
||||
<PostRow key={p.id} post={p} />
|
||||
<div key={p.id} className={p.id === freshPostId ? 'signal-enter' : undefined}>
|
||||
<PostRow post={p} selected={selectedPostId === p.id} onClick={() => {
|
||||
// Clear the day-list view first: the right panel renders
|
||||
// selectedDayPosts with higher priority than selectedPost,
|
||||
// so without this the detail never appears while a candle's
|
||||
// multi-post list is open.
|
||||
setSelectedDayPosts(null)
|
||||
setSelectedPostId(selectedPostId === p.id ? null : p.id)
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right rail */}
|
||||
<div className="rail">
|
||||
<div className="rail" ref={railRef}>
|
||||
{/* Day-view: all posts from clicked day */}
|
||||
{selectedDayPosts ? (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getTrades, getSignalAccuracy } from '@/lib/api'
|
||||
import type { BotTrade } from '@/types'
|
||||
import type { SignalAccuracy } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
|
||||
@@ -32,7 +33,8 @@ function fmtAccuracyPct(pct: number | null | undefined) {
|
||||
return pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
|
||||
}
|
||||
|
||||
function inPeriod(iso: string, period: Period) {
|
||||
function inPeriod(iso: string | null, period: Period) {
|
||||
if (!iso) return false // trades without closed_at are excluded from all windows
|
||||
if (period === 'All') return true
|
||||
const days = Number.parseInt(period, 10)
|
||||
if (Number.isNaN(days)) return true
|
||||
@@ -42,8 +44,8 @@ function inPeriod(iso: string, period: Period) {
|
||||
|
||||
function calcDrawdownPct(trades: BotTrade[]) {
|
||||
const ordered = [...trades]
|
||||
.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined)
|
||||
.sort((a, b) => new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime())
|
||||
.filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined && t.closed_at !== null)
|
||||
.sort((a, b) => new Date(a.closed_at!).getTime() - new Date(b.closed_at!).getTime())
|
||||
let equity = 0
|
||||
let peak = 0
|
||||
let maxDrawdownPct = 0
|
||||
@@ -78,6 +80,14 @@ export default function AnalyticsPageClient() {
|
||||
return () => { aliveRef.current = false }
|
||||
}, [])
|
||||
|
||||
// genRef guards loadAll against stale-closure wallet-switch races.
|
||||
// snapAddr === address inside an async function is a stale-closure trap:
|
||||
// both refer to the same closed-over value at render time, so the check
|
||||
// is always true even when the wallet has changed. genRef is a mutable
|
||||
// ref that any closure can read to detect it has been superseded.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
// `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button
|
||||
// load private data without a detour through the Settings page. Public
|
||||
// signal-accuracy always loads regardless.
|
||||
@@ -89,25 +99,37 @@ export default function AnalyticsPageClient() {
|
||||
// disagreed with the closed_at-based metric grid on the same screen. Limit
|
||||
// raised to 500 so the local computation covers ample history.
|
||||
async function loadAll(forcedEnv?: SignedEnvelope) {
|
||||
const accuracyPromise = swrFetch('signal-accuracy', 10 * 60_000, () => getSignalAccuracy()).catch(() => null)
|
||||
|
||||
if (!address || !isConnected) {
|
||||
setTrades([]); setAccuracy(null); setPrivateLocked(false)
|
||||
setTrades([]); setPrivateLocked(false)
|
||||
const a = await accuracyPromise
|
||||
if (aliveRef.current) setAccuracy(a)
|
||||
return
|
||||
}
|
||||
const tradesEnv = getCachedViewEnvelope('view_trades', address)
|
||||
?? (forcedEnv ?? getCachedViewEnvelope('view_user', address))
|
||||
|
||||
const snapAddr = address
|
||||
const gen = genRef.current
|
||||
const tradesEnv = getCachedViewEnvelope('view_trades', snapAddr)
|
||||
?? (forcedEnv ?? getCachedViewEnvelope('view_user', snapAddr))
|
||||
if (aliveRef.current) setPrivateLocked(!tradesEnv)
|
||||
try {
|
||||
const [t, a] = await Promise.all([
|
||||
tradesEnv ? getTrades(address, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
|
||||
getSignalAccuracy().catch(() => null),
|
||||
tradesEnv ? getTrades(snapAddr, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]),
|
||||
accuracyPromise,
|
||||
])
|
||||
if (aliveRef.current) { setTrades(t); setAccuracy(a) }
|
||||
// Discard if unmounted or if a newer loadAll call has started (wallet/tab change).
|
||||
if (aliveRef.current && gen === genRef.current) { setTrades(t); setAccuracy(a) }
|
||||
} catch {
|
||||
if (aliveRef.current) setTrades([])
|
||||
if (aliveRef.current && gen === genRef.current) setTrades([])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// B28/B36: clear stale previous-wallet data immediately before the async
|
||||
// fetch so the UI never shows another wallet's private P&L.
|
||||
setTrades([])
|
||||
setPrivateLocked(false)
|
||||
// Navigation only uses a cached envelope — never auto-popup the wallet.
|
||||
void loadAll()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -157,7 +179,8 @@ export default function AnalyticsPageClient() {
|
||||
const bestTrade = pnls.length ? Math.max(...pnls) : 0
|
||||
const worstTrade = pnls.length ? Math.min(...pnls) : 0
|
||||
const avgTrade = pnls.length ? totalPnl / pnls.length : 0
|
||||
const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0
|
||||
const heldTrades = filteredTrades.filter(t => t.hold_seconds !== null)
|
||||
const avgHold = heldTrades.length ? heldTrades.reduce((sum, trade) => sum + (trade.hold_seconds ?? 0), 0) / heldTrades.length : 0
|
||||
// One basis for every window: derive from the closed_at-filtered trades.
|
||||
const maxDrawdown = calcDrawdownPct(filteredTrades)
|
||||
const summaryPnl = totalPnl
|
||||
@@ -169,11 +192,11 @@ export default function AnalyticsPageClient() {
|
||||
tip: 'How many closed positions in this window. Open trades not counted.' },
|
||||
{ k: isZh ? '平均持仓时间' : 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: isZh ? '按单笔计算' : 'Per trade',
|
||||
tip: 'Entry → exit duration averaged across every closed trade.' },
|
||||
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: fmtMoney(avgTrade), sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0,
|
||||
{ k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: pnls.length ? fmtMoney(avgTrade) : '—', sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0,
|
||||
tip: 'Total P&L ÷ number of trades. Positive = the strategy has edge per trade.' },
|
||||
{ k: isZh ? '最佳单笔' : 'Best trade', v: fmtMoney(bestTrade), sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true,
|
||||
{ k: isZh ? '最佳单笔' : 'Best trade', v: pnls.length ? fmtMoney(bestTrade) : '—', sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true,
|
||||
tip: 'Biggest realized gain on one trade in this window.' },
|
||||
{ k: isZh ? '最差单笔' : 'Worst trade', v: fmtMoney(worstTrade), sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true,
|
||||
{ k: isZh ? '最差单笔' : 'Worst trade', v: pnls.length ? fmtMoney(worstTrade) : '—', sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true,
|
||||
tip: 'Biggest realized loss on one trade. Should be bounded by your stop-loss setting.' },
|
||||
]
|
||||
|
||||
@@ -183,8 +206,7 @@ export default function AnalyticsPageClient() {
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '分析面板' : 'Analytics'}</h1>
|
||||
<PageHint>
|
||||
Did the bot actually make money? Win rate, drawdown, average trade,
|
||||
and AI signal accuracy across the time window you pick on the right.
|
||||
P&L · win rate · drawdown · signal accuracy — pick a time window on the right.
|
||||
</PageHint>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||||
@@ -207,6 +229,71 @@ export default function AnalyticsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal accuracy — always public, shown first so non-connected visitors
|
||||
can immediately see proof of signal quality without needing to log in. */}
|
||||
{accuracy && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
|
||||
<div className="tiny">AI Signal Accuracy</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// One full-width block per measurement window. Big overall number
|
||||
// + a hit-rate bar (50% tick = coin-flip baseline) + per-direction
|
||||
// breakdown. `repeat(3, 1fr)` stretches across the card so no
|
||||
// whitespace is stranded on wide screens.
|
||||
const WINDOWS = ['m5', 'm15', 'm1h'] as const
|
||||
const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const
|
||||
const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
const fmtBreakdown = (pct: number | null, sparse: boolean) =>
|
||||
sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
|
||||
// auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the
|
||||
// viewport on phones and forced the whole page to scroll sideways.
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 }}>
|
||||
{WINDOWS.map(w => {
|
||||
const pct = accuracy.overall[w].accuracy_pct
|
||||
const has = pct != null && !Number.isNaN(pct)
|
||||
return (
|
||||
<div key={w} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'flex', alignItems: 'center' }}>
|
||||
{WINDOW_LABEL[w]}
|
||||
<InfoTip text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`} placement="top" />
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.01em', marginTop: 8, color: has ? pctColor(pct) : 'var(--ink-4)', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{has ? `${pct.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
{/* hit-rate bar with a 50% coin-flip tick */}
|
||||
<div style={{ position: 'relative', height: 6, background: 'var(--surface-3)', borderRadius: 999, marginTop: 10, overflow: 'hidden' }}>
|
||||
{has && (
|
||||
<div style={{ position: 'absolute', inset: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: pctColor(pct), borderRadius: 999, opacity: 0.75 }} />
|
||||
)}
|
||||
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1.5, background: 'var(--ink-4)', opacity: 0.55 }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: 'var(--ink-3)', flexWrap: 'wrap' }}>
|
||||
{Object.entries(accuracy.by_signal).map(([sig, d]) => {
|
||||
const sparse = d[w].checked < 2
|
||||
const v = fmtBreakdown(d[w].accuracy_pct, sparse)
|
||||
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
|
||||
return (
|
||||
<span key={sig} style={{ whiteSpace: 'nowrap' }}>
|
||||
{label}{' '}
|
||||
<strong style={{ color: sparse ? 'var(--ink-4)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}>{v}</strong>
|
||||
<span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaperView && filteredTrades.length > 0 && (
|
||||
<div className="card" style={{
|
||||
padding: '10px 16px', marginBottom: 16, fontSize: 12, fontWeight: 600,
|
||||
@@ -221,11 +308,11 @@ export default function AnalyticsPageClient() {
|
||||
|
||||
{privateLocked && (
|
||||
<div className="card" style={{ padding: 16, marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Private performance is locked.</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Your performance data is private.</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
Sign once to unlock your wallet-specific performance and trade history
|
||||
on this device (valid a few minutes, no transaction, no gas). Public
|
||||
signal-accuracy data below is always available.
|
||||
Sign once to unlock your P&L and trade history on this device
|
||||
(valid a few minutes — no transaction, no gas).
|
||||
Signal-accuracy stats below are always public.
|
||||
</div>
|
||||
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px', marginTop: 12 }}
|
||||
disabled={unlocking} onClick={handleUnlock}>
|
||||
@@ -244,10 +331,21 @@ export default function AnalyticsPageClient() {
|
||||
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
|
||||
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
|
||||
</div>
|
||||
<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">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
|
||||
</div>
|
||||
{/* Win-rate chip only when there are trades to rate — a red
|
||||
"0.0% win rate" next to an em-dash P&L misreads as a losing
|
||||
record when the wallet simply has no data. */}
|
||||
{pricedTrades.length > 0 && (
|
||||
<div className="row gap-s" style={{ marginTop: 8 }}>
|
||||
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredTrades.length === 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 12, lineHeight: 1.6, maxWidth: 520 }}>
|
||||
No closed bot trades in this window. P&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>
|
||||
@@ -265,62 +363,17 @@ export default function AnalyticsPageClient() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{accuracy && (
|
||||
<div className="card" style={{ padding: 24, marginBottom: 20 }}>
|
||||
<div className="tiny" style={{ marginBottom: 16 }}>{isZh ? `AI 信号准确率 · ${accuracy.total_directional_signals} 条方向性信号` : `AI Signal Accuracy · ${accuracy.total_directional_signals} directional signals`}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
||||
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{isZh ? '整体' : 'Overall'}</div>
|
||||
{(['m5','m15','m1h'] as const).map(w => {
|
||||
const d = accuracy.overall[w]
|
||||
const pct = d.accuracy_pct
|
||||
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w.replace('m','').replace('1h','1h')}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${accuracy.overall.m5.checked} 条已测` : `${accuracy.overall.m5.checked} measured`}</div>
|
||||
</div>
|
||||
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
|
||||
const label = sig === 'buy' ? (isZh ? '🟢 做多' : '🟢 Buy') : sig === 'short' ? (isZh ? '🔴 做空' : '🔴 Short') : (isZh ? '🟡 卖出' : '🟡 Sell')
|
||||
return (
|
||||
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
||||
<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>
|
||||
{(['m5','m15','m1h'] as const).map(w => {
|
||||
const d = data[w]
|
||||
if (d.checked < 2) return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>—</span>
|
||||
</div>
|
||||
)
|
||||
const pct = d.accuracy_pct
|
||||
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
||||
return (
|
||||
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${data.m5.checked} 条已测` : `${data.m5.checked} measured`}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Signal accuracy is now shown at the top of the page (public, no login needed).
|
||||
Removed duplicate block here to avoid showing the same data twice. */}
|
||||
|
||||
{!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)' }}>
|
||||
<p style={{ fontSize: 14 }}>
|
||||
{!isConnected || !address
|
||||
? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.')
|
||||
: 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.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Instant skeleton while AnalyticsPage loads. */
|
||||
export default function AnalyticsLoading() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head" style={{ marginBottom: 24 }}>
|
||||
<div className="skeleton sk-title" style={{ width: 160, marginBottom: 8 }} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" style={{ padding: 20 }}>
|
||||
<div className="skeleton sk-line sk-w-half" style={{ marginBottom: 12 }} />
|
||||
<div className="skeleton" style={{ height: 40, width: 100 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="skeleton-card" style={{ padding: 24 }}>
|
||||
<div className="skeleton sk-line" style={{ width: 140, marginBottom: 16 }} />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, marginBottom: 10 }}>
|
||||
<div className="skeleton sk-line" style={{ width: 80 }} />
|
||||
<div className="skeleton sk-line" style={{ width: 60 }} />
|
||||
<div className="skeleton sk-line sk-w-3q" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { TrumpPost } from '@/types'
|
||||
import { getPosts } from '@/lib/api'
|
||||
import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api'
|
||||
import { hasCached, swrFetch } from '@/lib/cache'
|
||||
import PostRow from '@/components/dashboard/PostCards'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import { buildArchiveFallbackResponse } from '@/lib/postPage'
|
||||
|
||||
const ARCHIVE_PAGE_SIZE = 30
|
||||
|
||||
const LIVE_SOURCES = new Set([
|
||||
'truth',
|
||||
'btc_bottom_reversal',
|
||||
'funding_reversal',
|
||||
'kol_divergence',
|
||||
])
|
||||
interface ArchivePageClientProps {
|
||||
initialData?: PostListResponse | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive — legacy / test signals (rsi_reversal, sma_reclaim, breakout,
|
||||
* test, phase1…). NOT a live system. Kept only so old data is inspectable.
|
||||
*/
|
||||
export default function ArchivePageClient() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [posts, setPosts] = useState<TrumpPost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
export default function ArchivePageClient({ initialData = null }: ArchivePageClientProps) {
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialData?.items ?? [])
|
||||
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
|
||||
const [sourceCounts, setSourceCounts] = useState(initialData?.source_counts ?? [])
|
||||
const [loading, setLoading] = useState(initialData === null)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [src, setSrc] = useState<string>('all')
|
||||
const [archivePage, setArchivePage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
getPosts(500, 1)
|
||||
.then(p => { setPosts(p); setLoadErr('') })
|
||||
.catch(e => setLoadErr(e instanceof Error ? e.message : (isZh ? '历史归档加载失败' : 'Failed to load archive')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [isZh])
|
||||
const key = `archive-page-${archivePage}-src-${src}`
|
||||
setLoadErr('')
|
||||
setLoading(posts.length === 0 && !hasCached(key))
|
||||
|
||||
// Archive = legacy / test data only. Exclude every live signal source so
|
||||
// active modules don't leak in here as users explore old experiments. Keep
|
||||
// this set in sync with sources emitted by app/services/scanners/*.
|
||||
const archivePosts = useMemo(
|
||||
() => posts.filter(p => !LIVE_SOURCES.has(p.source || '')),
|
||||
[posts],
|
||||
)
|
||||
const sources = useMemo(() => {
|
||||
const m: Record<string, number> = {}
|
||||
for (const p of archivePosts) m[p.source || '?'] = (m[p.source || '?'] || 0) + 1
|
||||
return Object.entries(m).sort((a, b) => b[1] - a[1])
|
||||
}, [archivePosts])
|
||||
const filtered = useMemo(
|
||||
() => src === 'all' ? archivePosts : archivePosts.filter(p => p.source === src),
|
||||
[archivePosts, src],
|
||||
)
|
||||
const archiveTotalPages = Math.max(1, Math.ceil(filtered.length / ARCHIVE_PAGE_SIZE))
|
||||
const archiveSafePage = Math.min(archivePage, archiveTotalPages)
|
||||
const archivePageItems = filtered.slice((archiveSafePage - 1) * ARCHIVE_PAGE_SIZE, archiveSafePage * ARCHIVE_PAGE_SIZE)
|
||||
swrFetch(
|
||||
key,
|
||||
3 * 60_000,
|
||||
() => getPostsPage(
|
||||
ARCHIVE_PAGE_SIZE,
|
||||
archivePage,
|
||||
undefined,
|
||||
{
|
||||
archiveOnly: true,
|
||||
sourceIn: src === 'all' ? undefined : [src],
|
||||
},
|
||||
),
|
||||
fresh => {
|
||||
setPosts(fresh.items)
|
||||
setTotalPosts(fresh.total)
|
||||
setSourceCounts(fresh.source_counts)
|
||||
},
|
||||
)
|
||||
.then(r => {
|
||||
setPosts(r.items)
|
||||
setTotalPosts(r.total)
|
||||
setSourceCounts(r.source_counts)
|
||||
})
|
||||
.catch(async e => {
|
||||
const detail = e instanceof Error ? e.message : 'Failed to load archive'
|
||||
if (!detail.includes('404')) {
|
||||
setLoadErr(detail)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const legacyPosts = await swrFetch(
|
||||
'archive-legacy-500',
|
||||
3 * 60_000,
|
||||
() => getPosts(500, 1),
|
||||
)
|
||||
const fallback = buildArchiveFallbackResponse(legacyPosts, archivePage, ARCHIVE_PAGE_SIZE, src)
|
||||
setPosts(fallback.items)
|
||||
setTotalPosts(fallback.total)
|
||||
setSourceCounts(fallback.source_counts)
|
||||
setLoadErr('')
|
||||
} catch (legacyErr) {
|
||||
setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [archivePage, posts.length, src])
|
||||
|
||||
const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE))
|
||||
const archiveSafePage = Math.min(archivePage, archiveTotalPages)
|
||||
const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0)
|
||||
const sourceTabs: [string, number][] = [
|
||||
['all', allSourcesCount],
|
||||
...sourceCounts.map(item => [item.source, item.count] as [string, number]),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Archive</h1>
|
||||
<PageHint count={`${archivePosts.length} legacy posts`}>
|
||||
Historical fires from old scanner experiments
|
||||
(rsi_reversal, sma_reclaim, breakout, test/phase1). Kept only for
|
||||
inspection — the bot doesn't act on these any more.
|
||||
{/* No count slot — the source tabs and pagination already show
|
||||
per-source and total counts. */}
|
||||
<PageHint>
|
||||
Signals from retired scanner experiments
|
||||
(rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only —
|
||||
the bot no longer acts on any of these.
|
||||
</PageHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{[['all', archivePosts.length] as [string, number], ...sources].map(([s, n]) => (
|
||||
{sourceTabs.map(([s, n]) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setSrc(s); setArchivePage(1) }}
|
||||
@@ -87,30 +116,30 @@ export default function ArchivePageClient() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>{isZh ? '加载中…' : 'Loading…'}</div>}
|
||||
{loading && <div style={{ textAlign: 'center', padding: 60, color: 'var(--ink-3)' }}>Loading…</div>}
|
||||
{!loading && loadErr && (
|
||||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--down)' }}>
|
||||
⚠️ {isZh ? `无法加载归档:${loadErr}` : `Couldn't load archive — ${loadErr}`}
|
||||
⚠️ {`Couldn't load archive — ${loadErr}`}
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<button className="btn ghost" style={{ fontSize: 12, padding: '6px 14px' }}
|
||||
onClick={() => location.reload()}>{isZh ? '重试' : 'Retry'}</button>
|
||||
onClick={() => location.reload()}>Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !loadErr && filtered.length === 0 && (
|
||||
{!loading && !loadErr && totalPosts === 0 && (
|
||||
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
{isZh ? '没有可显示的历史信号。' : 'No archived signals.'}
|
||||
No archived signals.
|
||||
</div>
|
||||
)}
|
||||
{!loading && archivePageItems.length > 0 && (
|
||||
{!loading && posts.length > 0 && (
|
||||
<>
|
||||
<div className="post-stream">
|
||||
{archivePageItems.map(p => <PostRow key={p.id} post={p} />)}
|
||||
{posts.map(p => <PostRow key={p.id} post={p} />)}
|
||||
</div>
|
||||
<Pagination
|
||||
page={archiveSafePage}
|
||||
total={archiveTotalPages}
|
||||
count={filtered.length}
|
||||
count={totalPosts}
|
||||
pageSize={ARCHIVE_PAGE_SIZE}
|
||||
onChange={setArchivePage}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
// Archive is legacy/test data only — not part of the live signal stack.
|
||||
// noindex keeps it out of search results (duplicate/thin content risk)
|
||||
// while keeping it accessible to logged-in users for inspection.
|
||||
export const revalidate = 60 // archive data rarely changes — ISR at 60s keeps server load low
|
||||
import type { Metadata } from 'next'
|
||||
import { type PostListResponse } from '@/lib/api'
|
||||
import { getPosts } from '@/lib/api'
|
||||
import { buildArchiveFallbackResponse, getInitialPostPage } from '@/lib/postPage'
|
||||
import ArchivePageClient from './ArchivePageClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function ArchivePage() {
|
||||
return <ArchivePageClient />
|
||||
export default async function ArchivePage() {
|
||||
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
|
||||
filters: { archiveOnly: true },
|
||||
legacyFallback: async () => {
|
||||
// Old backend (no /posts-paged archive_only): /posts caps at 500 per
|
||||
// page, so a single page only sees the latest 500 posts globally — older
|
||||
// archive rows get buried under fresh truth posts and silently dropped.
|
||||
// Page through a few windows so archive coverage isn't truncated.
|
||||
const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows
|
||||
const collected: Awaited<ReturnType<typeof getPosts>> = []
|
||||
for (let p = 1; p <= MAX_PAGES; p++) {
|
||||
const batch = await getPosts(500, p).catch(() => null)
|
||||
if (!batch || batch.length === 0) break
|
||||
collected.push(...batch)
|
||||
if (batch.length < 500) break // last page reached
|
||||
}
|
||||
return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null
|
||||
},
|
||||
})
|
||||
|
||||
return <ArchivePageClient initialData={initialData} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { permanentRedirect } from 'next/navigation'
|
||||
|
||||
interface LegacyBtcPageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
@@ -6,5 +6,5 @@ interface LegacyBtcPageProps {
|
||||
|
||||
export default async function LegacyBtcPage({ params }: LegacyBtcPageProps) {
|
||||
const { locale } = await params
|
||||
redirect(`/${locale}/macro`)
|
||||
permanentRedirect(`/${locale}/macro`)
|
||||
}
|
||||
|
||||
@@ -173,14 +173,14 @@ function getCopy(locale: string): CaseStudiesCopy {
|
||||
ogDescription:
|
||||
'Historical cases that show why the Trump, BTC, and KOL engines exist: posts, wallet behavior, price reaction, and evidence.',
|
||||
heroTitle: 'Case Studies',
|
||||
heroSubtitle: 'Real posts, real positioning, and real market reactions worth studying',
|
||||
heroSubtitle: 'Real events, real price moves — why these signals are worth tracking.',
|
||||
intro:
|
||||
'This is not a performance-claims page. It is an evidence page. Each case is here to answer three questions: what happened, how the market reacted, and why that event supports the existence of a specific signal engine.',
|
||||
'Three documented cases where the signal worked. Not forecasts — history.',
|
||||
statAsset: 'Asset',
|
||||
statImpact: 'Price impact',
|
||||
statImpact: 'Move',
|
||||
statWindow: 'Window',
|
||||
evidenceLabel: 'Evidence',
|
||||
footer: 'These cases illustrate the historical basis of the signal stack. They are not forward-looking return promises.',
|
||||
evidenceLabel: 'Source',
|
||||
footer: 'Past events don\'t guarantee future results. These cases show why the signal categories exist.',
|
||||
footerMethodology: 'Methodology',
|
||||
footerGlossary: 'Glossary',
|
||||
cases: [
|
||||
@@ -188,85 +188,51 @@ function getCopy(locale: string): CaseStudiesCopy {
|
||||
id: 'strategic-reserve-2025',
|
||||
date: 'March 2, 2025',
|
||||
source: 'Trump Truth Social',
|
||||
title: 'Strategic Crypto Reserve announcement',
|
||||
summary: 'Trump confirmed a U.S. Strategic Crypto Reserve including BTC, ETH, SOL, XRP, and ADA.',
|
||||
title: 'Trump announces U.S. Strategic Crypto Reserve — BTC +8%, ETH +10%',
|
||||
summary: 'Trump posted confirmation of a U.S. Strategic Crypto Reserve including BTC, ETH, SOL, XRP, and ADA. Markets moved within minutes.',
|
||||
signal: 'LONG',
|
||||
asset: 'BTC / ETH',
|
||||
priceImpact: 'BTC +8.2%, ETH +10%+',
|
||||
impactWindow: '24 hours',
|
||||
detail:
|
||||
'This case mattered because the post moved from vague political tone into explicit asset-level policy language. A signal engine should classify that as a high-conviction LONG event, because it names specific assets and ties them to a state-level commitment.',
|
||||
'The post named specific assets and tied them to a government-level commitment — the clearest possible bullish signal. This is exactly what the Trump signal engine is built to catch: a post that goes from vague political tone to explicit crypto policy in one statement.',
|
||||
evidence:
|
||||
'CNBC and Al Jazeera both documented the market response, and exchange plus on-chain data showed clear momentum-driven inflows after the post.',
|
||||
'CNBC and Al Jazeera documented the market response. Exchange data showed immediate momentum-driven inflows.',
|
||||
externalUrl:
|
||||
'https://www.cnbc.com/2025/03/02/trump-announces-strategic-crypto-reserve-including-bitcoin-solana-xrp-and-more.html',
|
||||
},
|
||||
{
|
||||
id: 'whale-6m-perp',
|
||||
date: 'March 2025',
|
||||
source: 'On-chain / Truth Social',
|
||||
title: '$6.8M pre-positioning profit around the reserve post',
|
||||
summary:
|
||||
'An anonymous address built a large leveraged BTC/ETH position around the reserve announcement and reportedly closed it the same day for roughly $6.8M profit.',
|
||||
signal: 'LONG',
|
||||
asset: 'BTC',
|
||||
priceImpact: '+$6.8M',
|
||||
impactWindow: 'Same day',
|
||||
detail:
|
||||
'This case highlights two facts at once. First, Trump-linked posts can create enough directional force to matter. Second, speed itself is edge in event-driven trading. Whether this was advance positioning or ultra-fast automation, it validates the post-to-trade-to-PnL chain.',
|
||||
evidence:
|
||||
'The trade was documented by Raw Story / NewsBreak, and TIME later referenced the political and regulatory scrutiny around the episode.',
|
||||
externalUrl:
|
||||
'https://www.newsbreak.com/raw-story-2096750/4286876592634-that-s-the-maga-playbook-crypto-trade-made-moments-before-trump-post-raises-eyebrows',
|
||||
},
|
||||
{
|
||||
id: 'tariff-china-2025',
|
||||
date: 'April 2025',
|
||||
source: 'Trump Truth Social',
|
||||
title: 'Tariff escalation post triggered a risk-off move',
|
||||
summary: 'A tariff-escalation post created a short-term macro risk-off setup rather than a bullish crypto reaction.',
|
||||
title: 'Tariff escalation post — BTC -4% in 4 hours',
|
||||
summary: 'A tariff-escalation post compressed risk appetite fast. BTC dropped 4.1% in 4 hours — no crypto-specific news, just macro sentiment repricing.',
|
||||
signal: 'SHORT',
|
||||
asset: 'BTC',
|
||||
priceImpact: 'BTC -4.1%',
|
||||
impactWindow: '4 hours',
|
||||
detail:
|
||||
'Not every Trump post is bullish for crypto. Posts framed around trade conflict, tariff escalation, tighter regulation, or macro uncertainty can compress risk appetite quickly. This case supports the need for a real SHORT branch in the signal engine.',
|
||||
'Not every Trump post is bullish for crypto. Trade conflict, tariffs, and macro uncertainty posts can hit crypto just as hard as equity markets. This is why the signal engine has a real SHORT branch — and why noise filtering matters.',
|
||||
evidence:
|
||||
'CoinDesk has documented multiple instances of Trump statements moving BTC. This post fits that broader historical pattern of immediate sentiment repricing.',
|
||||
'CoinDesk has documented multiple Trump statements that moved BTC in both directions.',
|
||||
externalUrl:
|
||||
'https://www.coindesk.com/markets/2026/04/20/five-times-president-trump-made-a-statement-that-moved-bitcoin-and-why-it-might-happen-again-this-week',
|
||||
},
|
||||
{
|
||||
id: 'kol-divergence-example',
|
||||
date: 'Illustrative composite',
|
||||
source: 'KOL post + on-chain wallet',
|
||||
title: 'Publicly bullish on ETH, privately reducing ETH',
|
||||
date: 'Composite example',
|
||||
source: 'KOL post + tracked wallet',
|
||||
title: 'KOL bullish on ETH in public, selling ETH on-chain',
|
||||
summary:
|
||||
'A tracked KOL published a bullish ETH thesis, then reduced roughly $180k of ETH exposure within five days. That is classic talks-vs-trades divergence.',
|
||||
'A tracked KOL published a bullish ETH thesis. Within 5 days, their wallet reduced ~$180k of ETH exposure. Public pitch said buy — the wallet said sell.',
|
||||
signal: 'DIVERGENCE',
|
||||
asset: 'ETH',
|
||||
priceImpact: 'N/A',
|
||||
impactWindow: '±7 days',
|
||||
detail:
|
||||
'This example is not about a single post moving price. It is about the information gap between public narrative and real positioning. For research-heavy users, that gap is often more valuable than a simple bullish or bearish statement.',
|
||||
'Words are free. Wallet moves cost money. When a KOL\'s on-chain behavior contradicts their public call, the wallet is usually telling the truth. This is why the KOL module tracks both — and why divergence is the highest-conviction category.',
|
||||
evidence:
|
||||
'The divergence classification comes from the platform’s cross-signal logic, which is documented in full on the Methodology page.',
|
||||
},
|
||||
{
|
||||
id: 'btc-bottom-nov-2022',
|
||||
date: 'November 2022',
|
||||
source: 'Historical BTC macro-bottom reference',
|
||||
title: 'The $15.5k FTX washout zone',
|
||||
summary:
|
||||
'After the FTX collapse, BTC entered the $15.5k region and later recovered toward $69k within roughly 18 months. This is the kind of regime the BTC macro-bottom engine is built to identify.',
|
||||
signal: 'LONG',
|
||||
asset: 'BTC',
|
||||
priceImpact: '+345%',
|
||||
impactWindow: '18 months',
|
||||
detail:
|
||||
'The current live BTC engine is not trying to replay an old premium-data model. It is trying to locate the same market structure with AHR999, the 200-week moving average, and Pi Cycle Bottom: deep value, long-cycle support, and emotional washout lining up together.',
|
||||
evidence:
|
||||
'Public price archives and long-range market charts consistently mark the FTX washout zone as the core bottom region of that bear market.',
|
||||
'Divergence detection logic is documented on the Methodology page.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+715
-314
File diff suppressed because it is too large
Load Diff
@@ -171,104 +171,105 @@ function getCopy(locale: string): GlossaryCopy {
|
||||
],
|
||||
ogTitle: 'Crypto Signals Glossary | Trump Alpha',
|
||||
ogDescription:
|
||||
'Clear definitions of Trump Alpha’s core terms, optimized for search, AI retrieval, and fast reference.',
|
||||
'Clear definitions of Trump Alpha\'s core terms, optimized for search, AI retrieval, and fast reference.',
|
||||
heroTitle: 'Glossary',
|
||||
heroSubtitle: 'The precise meaning of every key entity and term on the platform',
|
||||
heroSubtitle: 'Every term on the platform, defined precisely.',
|
||||
intro:
|
||||
'This glossary serves two jobs. First, it helps users understand the metrics and signal families inside Trump Alpha. Second, it gives search engines and LLMs a stable, retrieval-friendly map of the entities that appear across the product.',
|
||||
'Quick definitions for the terms you\'ll encounter on this platform.',
|
||||
terms: [
|
||||
{
|
||||
term: 'AHR999',
|
||||
category: 'Macro-bottom metric',
|
||||
category: 'Macro-bottom signal',
|
||||
definition:
|
||||
'AHR999 is a Bitcoin valuation indicator popular in Chinese crypto research. It combines long-term trend and cost-basis style anchors to estimate whether BTC is trading in a deep-value regime. In Trump Alpha, AHR999 below 0.45 counts as one classic bottom signal.',
|
||||
extra: 'It is one of the three inputs in the live BTC 2-of-3 macro-bottom scanner.',
|
||||
'A Bitcoin valuation score. Below 0.45 = BTC is historically cheap and in a potential bottom zone. Above 1.2 = overvalued, bottom thesis is off. One of three conditions in the BTC bottom scanner.',
|
||||
},
|
||||
{
|
||||
term: 'Pi Cycle Bottom',
|
||||
category: 'Macro-bottom metric',
|
||||
category: 'Macro-bottom signal',
|
||||
definition:
|
||||
'Pi Cycle Bottom is a Bitcoin bottom framework based on long-cycle moving-average relationships. In Trump Alpha, the condition is satisfied when the 150-day EMA falls below the 471-day SMA multiplied by 0.745.',
|
||||
extra: 'It is one of the three core inputs in the BTC macro-bottom scanner.',
|
||||
'A long-term moving-average signal that has historically aligned with Bitcoin cycle lows. Fires when the 150-day EMA crosses below the 471-day SMA × 0.745. Binary — either confirmed or not.',
|
||||
},
|
||||
{
|
||||
term: '200-week Moving Average',
|
||||
category: 'Macro-bottom metric',
|
||||
category: 'Macro-bottom signal',
|
||||
definition:
|
||||
'The 200-week moving average is one of Bitcoin’s most widely watched long-term trend anchors. Many historical bear-market lows formed near this zone. Trump Alpha treats price proximity to the 200-week MA as part of BTC bottom confluence.',
|
||||
'BTC\'s most-watched long-term support level. Every major bear-market low in history formed at or near it. The scanner counts price ≤ 200WMA × 1.05 as a bottom vote.',
|
||||
},
|
||||
{
|
||||
term: 'KOL',
|
||||
abbr: 'Key Opinion Leader',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'In crypto, a KOL is an analyst, fund manager, host, or creator whose public views consistently influence market narrative or retail positioning. Trump Alpha emphasizes long-form KOL sources, not only short social fragments.',
|
||||
'An analyst, fund manager, podcast host, or creator whose calls move crypto narrative. The platform tracks 25 KOLs — Arthur Hayes, Delphi, Bankless, and others — cross-checking what they say publicly against what their wallets actually do.',
|
||||
},
|
||||
{
|
||||
term: 'Talks-vs-Trades Divergence',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'Talks-vs-trades divergence happens when a KOL’s public stance and wallet behavior point in opposite directions on the same asset. Example: a KOL publishes a bullish ETH thesis while their wallet reduces ETH exposure that same week. Trump Alpha treats the wallet move as the higher-priority truth signal.',
|
||||
extra: 'This is one of the platform’s highest-information signals because it separates narrative from actual positioning.',
|
||||
'When a KOL\'s public call and their wallet move in opposite directions on the same asset. Example: publicly bullish ETH while quietly reducing ETH on-chain. The wallet is treated as the real signal — it\'s harder to fake than words.',
|
||||
extra: 'Highest-conviction signal category on the platform.',
|
||||
},
|
||||
{
|
||||
term: 'Conviction Score',
|
||||
term: 'Aligned',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'Conviction Score is the AI-generated strength score assigned to each extracted KOL view, typically ranging from 0.0 to 1.0. Higher values imply clearer language, stronger commitment, and better evidence of timing or sizing intent.',
|
||||
'A KOL\'s public call and their tracked wallet activity agree — e.g., bullish on BTC and the wallet is adding BTC. Reinforces the signal.',
|
||||
},
|
||||
{
|
||||
term: 'Mismatch',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'A KOL\'s public call contradicts their wallet move. Bullish in public, selling on-chain = mismatch. This is the divergence signal you actually want to act on.',
|
||||
},
|
||||
{
|
||||
term: 'Paper mode',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'Simulated trading — the bot runs through all its logic and tracks positions, but no real orders are sent to Hyperliquid. Use it to see how the bot would have performed before risking real money.',
|
||||
},
|
||||
{
|
||||
term: '/adopt',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'A Telegram bot command. When a Macro Vibes signal fires, you open the position yourself on Hyperliquid, then send /adopt in the bot. The bot then takes over managing the exit — stop ladder, partial de-risk, pyramiding — without you having to watch it.',
|
||||
},
|
||||
{
|
||||
term: 'AI Confidence',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'A percentage score (0–100%) the AI assigns to each Trump post signal. Higher = the post language is clearer and more explicitly directional. The default threshold is 70% — signals below this are not traded.',
|
||||
},
|
||||
{
|
||||
term: 'Funding Rate',
|
||||
category: 'Derivatives term',
|
||||
definition:
|
||||
'Funding rate is the periodic payment mechanism used by perpetual futures to keep contract pricing anchored to spot. If longs pay shorts, the market is long-crowded. If shorts pay longs, the market is short-crowded. Extreme readings often precede liquidation-driven reversals.',
|
||||
'The periodic payment between long and short traders on a perp exchange, designed to keep the contract price close to spot. Extreme positive funding = crowded longs. Extreme negative = crowded shorts. Extreme readings often precede sharp reversals when the crowded side gets squeezed.',
|
||||
},
|
||||
{
|
||||
term: 'Isolated Margin',
|
||||
category: 'Trading term',
|
||||
definition:
|
||||
'Isolated margin means each trade uses its own dedicated margin rather than sharing risk with the rest of the account. If one position is liquidated, it does not automatically cascade into other positions.',
|
||||
extra: 'Trump Alpha’s execution layer is designed around isolated margin.',
|
||||
'Each trade uses only its own dedicated margin — if it gets liquidated, it doesn\'t touch your other positions. Trump Alpha always uses isolated margin so one bad trade can\'t wipe the account.',
|
||||
},
|
||||
{
|
||||
term: 'TP / SL',
|
||||
abbr: 'Take-Profit / Stop-Loss',
|
||||
category: 'Trading term',
|
||||
definition:
|
||||
'TP and SL are conditional exit orders for profit-taking and loss control. Trump Alpha’s auto-trader attaches both at entry rather than after the position is already open.',
|
||||
},
|
||||
{
|
||||
term: 'UTXO',
|
||||
abbr: 'Unspent Transaction Output',
|
||||
category: 'Bitcoin term',
|
||||
definition:
|
||||
'UTXO is the fundamental accounting unit of the Bitcoin network. Trump Alpha’s live Macro Vibes module no longer relies directly on UTXO-spend behavior, but the concept still matters for interpreting classic on-chain research.',
|
||||
'Automatic exit orders placed at entry. TP closes the trade in profit at your target. SL closes it to cut losses. Trump Alpha attaches both the moment a trade opens.',
|
||||
},
|
||||
{
|
||||
term: 'Perpetual Future',
|
||||
abbr: 'Perp',
|
||||
category: 'Derivatives term',
|
||||
definition:
|
||||
'A perp is a futures contract with no expiry date. It stays open until the trader closes it or gets liquidated, while funding rate and mark-price mechanisms help keep it close to spot. Hyperliquid is one example of a perp venue.',
|
||||
},
|
||||
{
|
||||
term: 'Substack Signal',
|
||||
category: 'Platform term',
|
||||
definition:
|
||||
'A Substack signal is an extracted asset view taken from a KOL’s long-form essay, newsletter, or blog post. These sources usually contain fuller reasoning than short posts and therefore produce more retrieval-friendly signal data.',
|
||||
'A futures contract with no expiry. You stay in the trade until you close it or get liquidated. Trump Alpha executes on Hyperliquid perps.',
|
||||
},
|
||||
{
|
||||
term: 'Capitulation',
|
||||
category: 'Market term',
|
||||
definition:
|
||||
'Capitulation is the stage of a drawdown when holders finally give up and sell into weakness. It often appears near the end of a bear market and is one of the environments the BTC bottom scanner is designed to detect.',
|
||||
},
|
||||
{
|
||||
term: 'EMA',
|
||||
abbr: 'Exponential Moving Average',
|
||||
category: 'Technical term',
|
||||
definition:
|
||||
'EMA is a moving average that gives more weight to recent price data than older data. Trump Alpha uses the 150-day EMA inside the Pi Cycle Bottom condition.',
|
||||
'The phase near a bear market bottom where panicked holders give up and sell en masse. Creates the washout conditions that the BTC macro-bottom scanner is designed to detect.',
|
||||
},
|
||||
],
|
||||
footerLead: 'Missing a term?',
|
||||
|
||||
+236
-183
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type {
|
||||
@@ -42,8 +42,8 @@ function actionLabel(action: KolTicker['action'], isZh: boolean) {
|
||||
}
|
||||
|
||||
function windowLabel(days: number, isZh: boolean) {
|
||||
if (days === 1) return isZh ? '今日' : 'Today'
|
||||
return isZh ? `近 ${days} 日` : `Last ${days}d`
|
||||
if (days === 1) return 'Today'
|
||||
return `Last ${days}d`
|
||||
}
|
||||
|
||||
function changeLabel(changeType: KolHoldingChange['change_type'], isZh: boolean) {
|
||||
@@ -100,6 +100,7 @@ function chainActionLabel(action: string, isZh: boolean) {
|
||||
|
||||
function sourceLabel(source: string, isZh: boolean) {
|
||||
if (source === 'substack') return 'Substack'
|
||||
if (source === 'blog') return 'Blog'
|
||||
if (source === 'podcast') return 'Podcast'
|
||||
if (source === 'twitter') return 'X'
|
||||
return source
|
||||
@@ -138,19 +139,20 @@ function DigestWidget({
|
||||
activeTicker,
|
||||
isZh,
|
||||
initialDigest = null,
|
||||
days,
|
||||
}: {
|
||||
onTickerClick: (ticker: string) => void
|
||||
activeTicker?: string | null
|
||||
isZh: boolean
|
||||
initialDigest?: KolDigest | null
|
||||
days: number
|
||||
}) {
|
||||
const [days, setDays] = useState<number>(7)
|
||||
const [data, setData] = useState<KolDigest | null>(initialDigest)
|
||||
const [loading, setLoading] = useState(initialDigest === null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (initialDigest === null || days !== (initialDigest?.window_days ?? 7)) {
|
||||
if (initialDigest === null || days !== (initialDigest?.window_days ?? 30)) {
|
||||
setLoading(true)
|
||||
}
|
||||
swrFetch(
|
||||
@@ -160,64 +162,34 @@ function DigestWidget({
|
||||
fresh => setData(fresh),
|
||||
)
|
||||
.then(d => { setData(d); setErr('') })
|
||||
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load digest')))
|
||||
.catch(e => setErr(e instanceof Error ? e.message : ('Failed to load digest')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [days, initialDigest, isZh])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: 'clamp(12px, 3vw, 18px)',
|
||||
borderRadius: 12, background: 'var(--surface)',
|
||||
border: '1px solid var(--line)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)',
|
||||
letterSpacing: 1, textTransform: 'uppercase',
|
||||
marginBottom: 2 }}>
|
||||
What KOLs are pushing now
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{data
|
||||
? `${data.post_count} posts analysed · ${data.ticker_count} assets with repeat mentions`
|
||||
: 'Loading…'}
|
||||
</div>
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
{/* Compact meta line — no header label needed, context is obvious */}
|
||||
{data && !loading && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 8 }}>
|
||||
{data.post_count} posts · {data.ticker_count} assets
|
||||
</div>
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||||
{WINDOW_OPTIONS.map(daysOption => (
|
||||
<button
|
||||
key={daysOption}
|
||||
onClick={() => setDays(daysOption)}
|
||||
className={`nav-tab ${days === daysOption ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{windowLabel(daysOption, isZh)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
|
||||
fontSize: 12 }}>Loading…</div>
|
||||
<div style={{ padding: '8px 0', color: 'var(--ink-3)', fontSize: 12 }}>Loading…</div>
|
||||
)}
|
||||
{err && (
|
||||
<div style={{ padding: 12, color: '#dc2626', fontSize: 12 }}>{err}</div>
|
||||
)}
|
||||
{!loading && !err && data && data.tickers.length === 0 && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: 'var(--ink-3)',
|
||||
fontSize: 13 }}>
|
||||
No actionable calls in this window. Try a longer range.
|
||||
<div style={{ padding: '8px 0', color: 'var(--ink-3)', fontSize: 13 }}>
|
||||
No repeat mentions in this window.
|
||||
</div>
|
||||
)}
|
||||
{!loading && !err && data && data.tickers.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))',
|
||||
gap: 10,
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{data.tickers.map(t => <DigestTickerChip
|
||||
key={t.ticker}
|
||||
@@ -258,7 +230,8 @@ function DigestTickerChip({
|
||||
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
|
||||
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
|
||||
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%',
|
||||
transform: active ? 'translateY(-2px)' : 'none',
|
||||
opacity: hasActive && !active ? 0.6 : 1,
|
||||
@@ -267,7 +240,7 @@ function DigestTickerChip({
|
||||
aria-pressed={active}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<strong style={{ fontSize: 18, color: 'var(--ink-1)' }}>
|
||||
<strong style={{ fontSize: 18, color: 'var(--ink)' }}>
|
||||
{t.ticker}
|
||||
</strong>
|
||||
{active && (
|
||||
@@ -294,11 +267,10 @@ function DigestTickerChip({
|
||||
{actionLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-2)', fontWeight: 600 }}>
|
||||
{digestSideCopy(t.side)}
|
||||
</div>
|
||||
{/* Compact: KOL count + handles in two lines, no "Bullish flow" copy
|
||||
(Long/Short label already conveys direction). */}
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
|
||||
{t.kol_count} KOL · {(t.max_conviction * 100).toFixed(0)}% max conviction
|
||||
{t.kol_count} KOL{t.kol_count !== 1 ? 's' : ''} · {(t.max_conviction * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: 'var(--ink-3)',
|
||||
@@ -330,9 +302,11 @@ function WalletCheckWidget({
|
||||
initialChanges = null,
|
||||
initialItems = null,
|
||||
tickerFilter = null,
|
||||
days,
|
||||
}: {
|
||||
isZh: boolean
|
||||
dateLocale: string
|
||||
days: number
|
||||
initialDigest?: KolDigest | null
|
||||
initialChanges?: KolHoldingChange[] | null
|
||||
initialItems?: KolDivergence[] | null
|
||||
@@ -344,41 +318,48 @@ function WalletCheckWidget({
|
||||
const [loading, setLoading] = useState(
|
||||
initialDigest === null || initialChanges === null || initialItems === null,
|
||||
)
|
||||
const [days, setDays] = useState(7)
|
||||
// `days` now comes from the parent — no local state needed.
|
||||
|
||||
// genRef lets in-flight fetches detect they were superseded by a newer
|
||||
// days/filter change before writing back to state.
|
||||
const walletCheckGenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++walletCheckGenRef.current
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
swrFetch(
|
||||
`kol-digest-${days}`,
|
||||
15 * 60_000,
|
||||
() => getKolDigest(days),
|
||||
fresh => setDigest(fresh),
|
||||
fresh => { if (gen === walletCheckGenRef.current) setDigest(fresh) },
|
||||
),
|
||||
swrFetch(
|
||||
`kol-changes-${days}`,
|
||||
30 * 60_000,
|
||||
() => getKolChanges({ days }),
|
||||
fresh => setChanges(fresh.changes),
|
||||
fresh => { if (gen === walletCheckGenRef.current) setChanges(fresh.changes) },
|
||||
),
|
||||
swrFetch(
|
||||
`kol-divergence-all-${days}`,
|
||||
30 * 60_000,
|
||||
() => getKolDivergence({ days }),
|
||||
fresh => setItems(fresh.items),
|
||||
fresh => { if (gen === walletCheckGenRef.current) setItems(fresh.items) },
|
||||
),
|
||||
])
|
||||
.then(([nextDigest, nextChanges, nextItems]) => {
|
||||
if (gen !== walletCheckGenRef.current) return
|
||||
setDigest(nextDigest)
|
||||
setChanges(nextChanges.changes)
|
||||
setItems(nextItems.items)
|
||||
})
|
||||
.catch(() => {
|
||||
if (gen !== walletCheckGenRef.current) return
|
||||
setDigest(null)
|
||||
setChanges([])
|
||||
setItems([])
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
.finally(() => { if (gen === walletCheckGenRef.current) setLoading(false) })
|
||||
}, [days])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
@@ -458,44 +439,21 @@ function WalletCheckWidget({
|
||||
borderRadius: 12, background: 'var(--surface)',
|
||||
border: '1px solid var(--line)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
alignItems: 'flex-start', flexWrap: 'wrap', gap: 8, marginBottom: 12,
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: 1,
|
||||
textTransform: 'uppercase', marginBottom: 2 }}>
|
||||
Talks vs wallets
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{loading
|
||||
? 'Loading…'
|
||||
: rows.length === 0
|
||||
? 'No overlapping talk and wallet evidence in this window.'
|
||||
: `${totalAligned} aligned · ${totalMismatch} mismatched across the assets KOLs are pushing`}
|
||||
</div>
|
||||
{/* Compact summary — no uppercase header, no redundant label */}
|
||||
{!loading && rows.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-4)', marginBottom: 10 }}>
|
||||
{totalAligned > 0 && <span style={{ color: '#22c55e', marginRight: 8 }}>✓ {totalAligned} aligned</span>}
|
||||
{totalMismatch > 0 && <span style={{ color: '#f59e0b' }}>⚠ {totalMismatch} mismatch</span>}
|
||||
{totalAligned === 0 && totalMismatch === 0 && 'Wallet evidence pending'}
|
||||
</div>
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)' }}>
|
||||
{([1, 7, 30] as const).map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`nav-tab ${days === d ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{windowLabel(d, isZh)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<div style={{
|
||||
padding: '12px 16px', borderRadius: 8, background: 'var(--bg-sunk)',
|
||||
fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6,
|
||||
}}>
|
||||
This panel only answers one question: when KOLs talk about an asset, do tracked wallets confirm it?
|
||||
If there is no match yet, the asset stays in watch mode until wallet evidence appears.
|
||||
No wallet overlap for this window — try a wider range.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -522,22 +480,17 @@ function WalletCheckWidget({
|
||||
{row.verdict.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-1)', 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}
|
||||
</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 style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: 0.8, marginBottom: 5 }}>
|
||||
Wallet evidence
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-1)', fontWeight: 600, marginBottom: 4 }}>
|
||||
{/* No "WALLET EVIDENCE" label — context is clear from layout */}
|
||||
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
|
||||
{row.latestMatch
|
||||
? `@${row.latestMatch.handle} ${chainActionLabel(row.latestMatch.onchain_action, isZh).toLowerCase()}`
|
||||
: row.latestChange
|
||||
@@ -545,9 +498,13 @@ function WalletCheckWidget({
|
||||
: 'No tracked move yet'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||||
{row.latestMatch
|
||||
? `${row.verdict.note} ${row.latestMatch.usd_after ? `Size ${formatShortUsd(row.latestMatch.usd_after)}.` : ''}`
|
||||
: row.verdict.note}
|
||||
{/* Drop boilerplate "Wallet action supports/disagrees the public pitch." —
|
||||
the verdict badge already says Aligned / Mismatch. Just show the size. */}
|
||||
{row.latestMatch?.usd_after
|
||||
? `Size ${formatShortUsd(row.latestMatch.usd_after)}`
|
||||
: row.verdict.label === 'No wallet proof'
|
||||
? 'No tracked wallet move in this window'
|
||||
: ''}
|
||||
</div>
|
||||
{(row.divergenceCount > 0 || row.alignmentCount > 0) && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
@@ -581,9 +538,9 @@ function WalletCheckWidget({
|
||||
}
|
||||
|
||||
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
|
||||
if (!tickers.length) {
|
||||
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}>—</span>
|
||||
}
|
||||
// No tickers → render nothing. A lone "—" dangled under every card whose
|
||||
// post had no extracted assets (most of the feed).
|
||||
if (!tickers.length) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{tickers.map((t, i) => (
|
||||
@@ -661,7 +618,7 @@ function PostDetail({
|
||||
margin: 0, fontSize: 'clamp(16px, 4.5vw, 22px)',
|
||||
lineHeight: 1.3, wordBreak: 'break-word',
|
||||
}}>
|
||||
{post.title || (isZh ? '(无标题)' : '(Untitled)')}
|
||||
{post.title || post.summary || (post.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -680,7 +637,7 @@ function PostDetail({
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isZh ? `查看原帖 ${postSourceLabel}` : `Open original ${postSourceLabel}`} ↗
|
||||
{`Open original ${postSourceLabel}`} ↗
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
@@ -704,7 +661,7 @@ function PostDetail({
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4,
|
||||
letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||
{isZh ? 'AI 摘要' : 'AI summary'}
|
||||
{'AI summary'}
|
||||
</div>
|
||||
{post.summary}
|
||||
</div>
|
||||
@@ -715,7 +672,7 @@ function PostDetail({
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 6,
|
||||
letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||
{isZh ? '提取标的' : 'Extracted assets'}
|
||||
{'Extracted assets'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{post.tickers.map((t, i) => (
|
||||
@@ -730,7 +687,7 @@ function PostDetail({
|
||||
{actionLabel(t.action, isZh)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{isZh ? '信心分' : 'Conviction'} {(t.conviction * 100).toFixed(0)}%
|
||||
{'Conviction'} {(t.conviction * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-2)',
|
||||
@@ -749,7 +706,7 @@ function PostDetail({
|
||||
}}>
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 8,
|
||||
letterSpacing: 1, textTransform: 'uppercase' }}>
|
||||
{isZh ? '原文摘录' : 'Source excerpt'}
|
||||
{'Source excerpt'}
|
||||
</div>
|
||||
<div style={{
|
||||
whiteSpace: 'pre-wrap', fontSize: 13, lineHeight: 1.7,
|
||||
@@ -768,8 +725,12 @@ function PostDetail({
|
||||
return createPortal(node, document.body)
|
||||
}
|
||||
|
||||
type SourceFilter = 'all' | 'substack' | 'blog' | 'podcast' | 'twitter'
|
||||
const KOL_SERVER_PAGE_SIZE = 50
|
||||
|
||||
interface KolPageProps {
|
||||
initialPosts?: KolPostSummary[] | null
|
||||
initialTotal?: number
|
||||
initialDigest?: KolDigest | null
|
||||
initialChanges?: KolHoldingChange[] | null
|
||||
initialDivergence?: KolDivergence[] | null
|
||||
@@ -777,79 +738,144 @@ interface KolPageProps {
|
||||
|
||||
export default function KolPage({
|
||||
initialPosts = null,
|
||||
initialTotal = 0,
|
||||
initialDigest = null,
|
||||
initialChanges = null,
|
||||
initialDivergence = null,
|
||||
}: KolPageProps) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const dateLocale = isZh ? 'zh-CN' : 'en-US'
|
||||
const KOL_PAGE_SIZE = 20
|
||||
const [posts, setPosts] = useState<KolPostSummary[]>(initialPosts ?? [])
|
||||
const [loading, setLoading] = useState(initialPosts === null)
|
||||
const [err, setErr] = useState('')
|
||||
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
|
||||
const [handleFilter, setHandleFilter] = useState<string>('all')
|
||||
const [tickerFilter, setTickerFilter] = useState<string | null>(null)
|
||||
const [kolPage, setKolPage] = useState(1)
|
||||
const dateLocale = 'en-US'
|
||||
const isZh = false // i18n shelved — passed to helper fns that still take the param
|
||||
const [posts, setPosts] = useState<KolPostSummary[]>(initialPosts ?? [])
|
||||
const [serverTotal, setServerTotal] = useState(initialTotal)
|
||||
const [loading, setLoading] = useState(initialPosts === null)
|
||||
const [err, setErr] = useState('')
|
||||
const [openPost, setOpenPost] = useState<KolPostDetail | null>(null)
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
const [signalsOnly, setSignalsOnly] = useState(false)
|
||||
const [tickerFilter, setTickerFilter] = useState<string | null>(null)
|
||||
const [serverPage, setServerPage] = useState(1)
|
||||
// Unified time window — controls both DigestWidget and WalletCheckWidget.
|
||||
// Default 30d, not 7d: KOL ingestion is daily and sparse, so a 7d window is
|
||||
// frequently empty (e.g. 7d=0 while 30d=196) and the first paint showed
|
||||
// "No data yet" for modules that actually have data.
|
||||
const [kolDays, setKolDays] = useState(30)
|
||||
|
||||
const postsGenRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++postsGenRef.current
|
||||
setLoading(true)
|
||||
const src = sourceFilter === 'all' ? undefined : sourceFilter
|
||||
const cacheKey = `kol-posts-${sourceFilter}-${signalsOnly}-${serverPage}-${tickerFilter ?? ''}-${kolDays}`
|
||||
swrFetch(
|
||||
'kol-posts-100',
|
||||
cacheKey,
|
||||
15 * 60_000, // 15 min TTL — KOL feed is ingested daily
|
||||
() => getKolPosts({ limit: 100 }),
|
||||
fresh => setPosts(fresh.items),
|
||||
() => getKolPosts({
|
||||
limit: KOL_SERVER_PAGE_SIZE, page: serverPage, source: src, signalsOnly,
|
||||
ticker: tickerFilter ?? undefined, days: kolDays,
|
||||
}),
|
||||
fresh => { if (gen === postsGenRef.current) { setPosts(fresh.items); setServerTotal(fresh.total ?? 0) } },
|
||||
)
|
||||
.then(r => { setPosts(r.items); setErr('') })
|
||||
.catch(e => setErr(e instanceof Error ? e.message : (isZh ? '加载失败' : 'Failed to load posts')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [isZh])
|
||||
.then(r => {
|
||||
if (gen !== postsGenRef.current) return
|
||||
setPosts(r.items); setServerTotal(r.total ?? 0); setErr('')
|
||||
})
|
||||
.catch(e => { if (gen === postsGenRef.current) setErr(e instanceof Error ? e.message : ('Failed to load posts')) })
|
||||
.finally(() => { if (gen === postsGenRef.current) setLoading(false) })
|
||||
}, [sourceFilter, signalsOnly, serverPage, tickerFilter, kolDays])
|
||||
|
||||
const handles = useMemo(() => {
|
||||
const set = new Set(posts.map(p => p.kol_handle))
|
||||
return ['all', ...Array.from(set)]
|
||||
}, [posts])
|
||||
// posts are already filtered server-side; no client-side re-filter needed
|
||||
const filtered = posts
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let out = handleFilter === 'all' ? posts : posts.filter(p => p.kol_handle === handleFilter)
|
||||
if (tickerFilter) {
|
||||
out = out.filter(p => p.tickers.some(t => t.ticker === tickerFilter))
|
||||
}
|
||||
return out
|
||||
}, [posts, handleFilter, tickerFilter])
|
||||
|
||||
const kolTotalPages = Math.max(1, Math.ceil(filtered.length / KOL_PAGE_SIZE))
|
||||
const kolSafePage = Math.min(kolPage, kolTotalPages)
|
||||
const kolPageItems = filtered.slice((kolSafePage - 1) * KOL_PAGE_SIZE, kolSafePage * KOL_PAGE_SIZE)
|
||||
const totalServerPages = Math.max(1, Math.ceil(serverTotal / KOL_SERVER_PAGE_SIZE))
|
||||
|
||||
async function openDetail(id: number) {
|
||||
try {
|
||||
const detail = await getKolPost(id)
|
||||
setOpenPost(detail)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : (isZh ? '详情加载失败' : 'Failed to load detail'))
|
||||
setErr(e instanceof Error ? e.message : ('Failed to load detail'))
|
||||
}
|
||||
}
|
||||
|
||||
function changeSource(src: SourceFilter) {
|
||||
setSourceFilter(src)
|
||||
setServerPage(1)
|
||||
setTickerFilter(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? 'KOL 信号' : 'KOL Signals'}</h1>
|
||||
<PageHint count={`${posts.length} posts`}>
|
||||
This page answers two questions only: which assets KOLs are pushing now,
|
||||
and whether tracked wallets confirm or contradict that public stance.
|
||||
<h1 className="page-title">{'KOL Signals'}</h1>
|
||||
{/* 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.
|
||||
</PageHint>
|
||||
</div>
|
||||
{/* Single time filter controlling both widgets */}
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', alignSelf: 'flex-start' }}>
|
||||
{WINDOW_OPTIONS.map(d => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => { setKolDays(d); setServerPage(1) }}
|
||||
className={`nav-tab ${kolDays === d ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{windowLabel(d, isZh)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source filter — All / Substack / X + Signals only toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{(['all', 'substack', 'blog', 'podcast', 'twitter'] as SourceFilter[]).map(src => {
|
||||
const label = src === 'all' ? 'All' : src === 'substack' ? 'Substack' : src === 'blog' ? 'Blog' : src === 'podcast' ? 'Podcast' : 'X (Twitter)'
|
||||
const active = sourceFilter === src
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => changeSource(src)}
|
||||
style={{
|
||||
padding: '4px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer', border: '1px solid var(--line)',
|
||||
background: active ? 'var(--amber)' : 'var(--surface)',
|
||||
color: active ? '#000' : 'var(--ink-2)',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setSignalsOnly(v => !v); setServerPage(1) }}
|
||||
title="Exclude noise posts (short tweets, gm, RT). Long-form and directional posts are kept."
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
padding: '4px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${signalsOnly ? '#16a34a88' : 'var(--line)'}`,
|
||||
background: signalsOnly ? '#16a34a22' : 'var(--surface)',
|
||||
color: signalsOnly ? '#16a34a' : 'var(--ink-3)',
|
||||
transition: 'background 0.15s, color 0.15s, border-color 0.15s',
|
||||
}}
|
||||
>
|
||||
{signalsOnly ? '✓ Signals only' : 'Signals only'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DigestWidget
|
||||
isZh={isZh}
|
||||
initialDigest={initialDigest}
|
||||
activeTicker={tickerFilter}
|
||||
days={kolDays}
|
||||
onTickerClick={(sym) => {
|
||||
setTickerFilter(prev => prev === sym ? null : sym)
|
||||
setKolPage(1)
|
||||
setServerPage(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -860,6 +886,7 @@ export default function KolPage({
|
||||
initialChanges={initialChanges}
|
||||
initialItems={initialDivergence}
|
||||
tickerFilter={tickerFilter}
|
||||
days={kolDays}
|
||||
/>
|
||||
|
||||
{tickerFilter && (
|
||||
@@ -867,47 +894,38 @@ export default function KolPage({
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
marginBottom: 12, fontSize: 12,
|
||||
}}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '当前筛选标的:' : 'Filtered asset:'}</span>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{'Filtered asset:'}</span>
|
||||
<strong>{tickerFilter}</strong>
|
||||
<button
|
||||
onClick={() => { setTickerFilter(null); setKolPage(1) }}
|
||||
onClick={() => { setTickerFilter(null); setServerPage(1) }}
|
||||
style={{
|
||||
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
|
||||
borderRadius: 4, padding: '2px 8px',
|
||||
cursor: 'pointer', fontSize: 11, color: 'var(--ink-2)',
|
||||
}}
|
||||
>{isZh ? '清除 ✕' : 'Clear ✕'}</button>
|
||||
>{'Clear ✕'}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{handles.length > 2 && (
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', marginBottom: 12 }}>
|
||||
{handles.map(h => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => { setHandleFilter(h); setKolPage(1) }}
|
||||
className={`nav-tab ${handleFilter === h ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{h === 'all' ? (isZh ? `全部 (${posts.length})` : `All (${posts.length})`) : `@${h}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* KOL handle filter removed — 25 handles as a horizontal tab bar
|
||||
overflows on every screen size and most users filter by asset (ticker),
|
||||
not by person. Handle is visible on each post card. */}
|
||||
|
||||
<div style={{ margin: '12px 0 4px', fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
All posts · paginated feed
|
||||
</div>
|
||||
|
||||
{loading && <KolPostsSkeleton />}
|
||||
{err && <div style={{ padding: 20, color: '#dc2626' }}>{isZh ? `错误:${err}` : `Error: ${err}`}</div>}
|
||||
{err && <div style={{ padding: 20, color: '#dc2626' }}>{`Error: ${err}`}</div>}
|
||||
|
||||
{!loading && !err && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
{isZh
|
||||
? '当前没有数据,等待下一轮抓取任务(每天 01:15 UTC)。'
|
||||
: 'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
|
||||
{'No data yet. Wait for the next feed ingestion run (daily 01:15 UTC).'}
|
||||
</div>
|
||||
)}
|
||||
{kolPageItems.map(p => (
|
||||
{filtered.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => openDetail(p.id)}
|
||||
@@ -929,10 +947,44 @@ export default function KolPage({
|
||||
@{p.kol_handle} · {sourceLabel(p.source, isZh)} · {new Date(p.published_at).toLocaleDateString(dateLocale)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
{p.tier === 'trade_signal' && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.04em',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: '#16a34a22', color: '#16a34a',
|
||||
border: '1px solid #16a34a44',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
SIGNAL
|
||||
</span>
|
||||
)}
|
||||
{p.tier === 'directional' && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, letterSpacing: '0.04em',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: '#f59e0b22', color: '#f59e0b',
|
||||
border: '1px solid #f59e0b44',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
VIEW
|
||||
</span>
|
||||
)}
|
||||
{p.talks_vs_trades_flag && (
|
||||
<span title="Public stance contradicts revealed position in this post"
|
||||
style={{
|
||||
fontSize: 10, fontWeight: 700,
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: '#ef444422', color: '#ef4444',
|
||||
border: '1px solid #ef444444',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
⚠ FLIP
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{p.analyzed_at
|
||||
? (isZh ? '✓ 已分析' : '✓ Analyzed')
|
||||
: (isZh ? '⏳ 待分析' : '⏳ Pending')}
|
||||
? ('✓ Analyzed')
|
||||
: ('⏳ Pending')}
|
||||
</span>
|
||||
{p.url && (
|
||||
<a
|
||||
@@ -948,16 +1000,17 @@ export default function KolPage({
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{isZh ? '原帖 ↗' : 'Source ↗'}
|
||||
{'Source ↗'}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 6,
|
||||
wordBreak: 'break-word' }}>
|
||||
{p.title || (isZh ? '(无标题)' : '(Untitled)')}
|
||||
{p.title || p.summary || (p.source === 'twitter' ? '(Tweet)' : '(Untitled)')}
|
||||
</div>
|
||||
{p.summary && (
|
||||
{/* Only show summary as a separate line when there's a real title above it */}
|
||||
{p.title && p.summary && (
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.5,
|
||||
marginBottom: 8 }}>
|
||||
{p.summary}
|
||||
@@ -968,11 +1021,11 @@ export default function KolPage({
|
||||
))}
|
||||
|
||||
<Pagination
|
||||
page={kolSafePage}
|
||||
total={kolTotalPages}
|
||||
count={filtered.length}
|
||||
pageSize={KOL_PAGE_SIZE}
|
||||
onChange={setKolPage}
|
||||
page={serverPage}
|
||||
total={totalServerPages}
|
||||
count={serverTotal}
|
||||
pageSize={KOL_SERVER_PAGE_SIZE}
|
||||
onChange={setServerPage}
|
||||
scrollTop={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Instant skeleton while KolPage fetches posts and digest. */
|
||||
export default function KolLoading() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="skeleton sk-title" style={{ width: 140, marginBottom: 8 }} />
|
||||
<div className="skeleton sk-line" style={{ width: 260 }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" style={{ marginBottom: 10, padding: 18 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 10 }}>
|
||||
<div className="skeleton" style={{ width: 36, height: 36, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="skeleton sk-line" style={{ width: 120, marginBottom: 6 }} />
|
||||
<div className="skeleton sk-line-sm" style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="skeleton sk-line" style={{ width: 60 }} />
|
||||
</div>
|
||||
<div className="skeleton sk-line sk-w-full" style={{ marginBottom: 6 }} />
|
||||
<div className="skeleton sk-line sk-w-3q" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export async function generateMetadata({
|
||||
return {
|
||||
title: isZh ? 'KOL 信号与言行偏离追踪' : 'KOL Signals & Talks-vs-Trades Divergence',
|
||||
description: isZh
|
||||
? '从 15 个加密 KOL 信息源中提取可执行观点,覆盖 Arthur Hayes、Delphi Digital、Bankless、Empire、Unchained 等,并追踪“说法”和链上仓位是否一致。'
|
||||
: 'AI-extracted crypto signals from 19 KOL feeds: Arthur Hayes, Delphi Digital, Bankless, Empire, Unchained and more. Includes talks-vs-trades divergence — when KOL wallets contradict their public posts.',
|
||||
? '从 25 个加密 KOL 信息源中提取可执行观点,覆盖 Arthur Hayes、Delphi Digital、Bankless、Empire、Unchained 等,并追踪“说法”和链上仓位是否一致。'
|
||||
: 'AI-extracted crypto signals from 25 KOL feeds: Arthur Hayes, Delphi Digital, Bankless, Empire, Unchained and more. Includes talks-vs-trades divergence — when KOL wallets contradict their public posts.',
|
||||
keywords: isZh
|
||||
? [
|
||||
'KOL 加密信号',
|
||||
@@ -48,7 +48,7 @@ export async function generateMetadata({
|
||||
title: isZh ? 'KOL 信号与言行偏离 | Trump Alpha' : 'KOL Signals & Talks-vs-Trades | Trump Alpha',
|
||||
description: isZh
|
||||
? '每天分析 KOL 长文、播客和公开观点,再与链上仓位交叉验证,找出真正有信息增量的观点与偏离。'
|
||||
: '19 KOL feeds (Hayes, Bankless, Empire…) AI-scored daily. Plus talks-vs-trades: when their wallets contradict their words.',
|
||||
: '25 KOL feeds (Hayes, Bankless, Empire…) AI-scored daily. Plus talks-vs-trades: when their wallets contradict their words.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/en/kol`,
|
||||
@@ -60,7 +60,7 @@ export async function generateMetadata({
|
||||
}
|
||||
}
|
||||
|
||||
// GEO: the KOL talks-vs-trades feed is a dataset cross-referencing 19 crypto
|
||||
// GEO: the KOL talks-vs-trades feed is a dataset cross-referencing 25 crypto
|
||||
// KOLs' public statements against their on-chain wallet behaviour.
|
||||
const kolDataset = {
|
||||
'@context': 'https://schema.org',
|
||||
@@ -68,7 +68,7 @@ const kolDataset = {
|
||||
'@id': `${siteUrl}/en/kol#dataset`,
|
||||
name: 'Crypto KOL talks-vs-trades divergence dataset',
|
||||
description:
|
||||
'Daily cross-reference of 19 crypto KOLs\' public statements (Substack / podcast) against their on-chain Ethereum wallet activity, flagging divergence when public stance and real positioning disagree within a ±7-day window.',
|
||||
'Daily cross-reference of 25 crypto KOLs\' public statements (Substack / podcast) against their on-chain Ethereum wallet activity, flagging divergence when public stance and real positioning disagree within a ±7-day window.',
|
||||
url: `${siteUrl}/en/kol`,
|
||||
keywords: ['crypto KOL', 'on-chain', 'divergence', 'wallet tracking', 'sentiment'],
|
||||
isAccessibleForFree: true,
|
||||
@@ -80,10 +80,13 @@ const kolDataset = {
|
||||
}
|
||||
|
||||
export default async function KolPage() {
|
||||
// days=30 must match the client default (KolPageClient kolDays=30) so the
|
||||
// SSR payload and first client render agree — otherwise content flashes on
|
||||
// mount. 7d is frequently empty while 30d has data.
|
||||
const [posts, digest, changes, divergence] = await Promise.all([
|
||||
getKolPosts({ limit: 100 }).catch(() => null),
|
||||
getKolDigest(7).catch(() => null),
|
||||
getKolChanges({ days: 7 }).catch(() => null),
|
||||
getKolPosts({ limit: 50, page: 1, days: 30 }).catch(() => null),
|
||||
getKolDigest(30).catch(() => null),
|
||||
getKolChanges({ days: 30 }).catch(() => null),
|
||||
getKolDivergence({ days: 30 }).catch(() => null),
|
||||
])
|
||||
|
||||
@@ -96,6 +99,7 @@ export default async function KolPage() {
|
||||
<Breadcrumbs items={[{ name: 'KOL talks-vs-trades', path: '/en/kol' }]} />
|
||||
<KolPageClient
|
||||
initialPosts={posts?.items ?? null}
|
||||
initialTotal={posts?.total ?? 0}
|
||||
initialDigest={digest}
|
||||
initialChanges={changes?.changes ?? null}
|
||||
initialDivergence={divergence?.items ?? null}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { locales } from '@/i18n'
|
||||
import Providers from './Providers'
|
||||
import Navbar from '@/components/nav/Navbar'
|
||||
import TradeAlertBanner from '@/components/ui/TradeAlertBanner'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
|
||||
|
||||
@@ -46,6 +47,7 @@ export default async function LocaleLayout({ children, params }: LayoutProps) {
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>
|
||||
<Navbar />
|
||||
<TradeAlertBanner />
|
||||
<main>{children}</main>
|
||||
<footer style={{
|
||||
borderTop: '1px solid var(--line)',
|
||||
|
||||
@@ -9,7 +9,6 @@ import { swrFetch } from '@/lib/cache'
|
||||
import PostRow from '@/components/dashboard/PostCards'
|
||||
import SystemControl from '@/components/signals/SystemControl'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
|
||||
// MacroPanel is 631 lines with heavy indicator math — split it out.
|
||||
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
|
||||
@@ -84,67 +83,52 @@ export default function MacroVibesPage({
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
|
||||
{/* PageHint: brand tagline for the page — same across both tabs so
|
||||
the "what is this page" answer doesn't shift under the user when
|
||||
they click between Bottom / Funding. Tab-specific notes live in
|
||||
the section-hint card right below the tab bar. */}
|
||||
<PageHint>
|
||||
Macro vibes for crypto. Read what's about to happen before price prints it.
|
||||
</PageHint>
|
||||
{/* No "You open · bot manages" badge — the SystemControl strip right
|
||||
below says the same thing verbatim ("You open · bot manages exit"). */}
|
||||
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
|
||||
</div>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
</div>
|
||||
|
||||
<SystemControl system="btc" />
|
||||
|
||||
<div className="nav-tabs" style={{
|
||||
background: 'var(--bg-sunk)', marginTop: 16, marginBottom: 12,
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setTab('bottom')}
|
||||
className={`nav-tab ${tab === 'bottom' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '周期底部' : 'Macro Bottom'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('funding')}
|
||||
className={`nav-tab ${tab === 'funding' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '资金费率反转' : 'Funding Reversal'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Always-visible per-tab explanation. Was hidden behind a hover
|
||||
tooltip on the tab button — users couldn't tell what they were
|
||||
about to look at until they explicitly hovered. */}
|
||||
{tab === 'bottom' && (
|
||||
<div className="section-hint">
|
||||
Fires when <strong>≥2 of 3</strong> classic bottom signals agree:{' '}
|
||||
<strong>AHR999 < 0.45</strong> · <strong>price ≤ 200-week MA</strong>
|
||||
{' '}· <strong>Pi Cycle Bottom</strong>. Long only, 2–4 fires per cycle.
|
||||
Holds up to 18 months with a trailing-stop exit — no take-profit.
|
||||
{/* Tabs + inline description on the same row — avoids a separate
|
||||
"section hint" block that adds a third layer before the data. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16,
|
||||
flexWrap: 'wrap', marginTop: 14, marginBottom: 14 }}>
|
||||
<div className="nav-tabs" style={{ background: 'var(--bg-sunk)', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setTab('bottom')}
|
||||
className={`nav-tab ${tab === 'bottom' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '周期底部' : 'Macro Bottom'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('funding')}
|
||||
className={`nav-tab ${tab === 'funding' ? 'active' : ''}`}
|
||||
style={{ border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
{isZh ? '资金费率反转' : 'Funding Reversal'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.4 }}>
|
||||
{tab === 'bottom'
|
||||
? <><strong style={{ color: 'var(--ink-2)' }}>≥2 of 3:</strong> AHR999 < 0.45 · 200w MA · Pi Cycle Bottom — long-only, rare.</>
|
||||
: <>Fades crowded perps when 30d cumulative funding crosses <strong>±3%</strong> and cools. Hourly.</>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Macro indicator panel — only relevant on the Macro Bottom tab where
|
||||
users are reasoning about the broader risk regime. The Funding tab
|
||||
has its own live funding panel below. */}
|
||||
{tab === 'bottom' && <MacroPanel />}
|
||||
{tab === 'funding' && (
|
||||
<div className="section-hint">
|
||||
Mean-reversion against crowded perp positioning. When 30-day cumulative
|
||||
funding crosses <strong>±3%</strong> AND the most recent cycles start
|
||||
cooling off, the scanner bets against the side that's been paying for
|
||||
weeks. Hourly check.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Old plain-text per-tab description removed — the same content
|
||||
now lives in the section-hint block above the tab content. */}
|
||||
|
||||
{/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the
|
||||
backend scanner is disabled (services/funding_signal.py _enabled=False,
|
||||
operator-only toggle), so the panel only ever showed "Paused / No
|
||||
signals yet" — and breakout scanning is unrelated to funding reversal
|
||||
anyway. Component kept at components/dashboard/SignalMonitor.tsx;
|
||||
remount here if the scanner is ever re-enabled. */}
|
||||
{tab === 'funding' && (
|
||||
<FundingPanel
|
||||
isZh={isZh}
|
||||
@@ -181,13 +165,11 @@ export default function MacroVibesPage({
|
||||
)}
|
||||
{!loading && !loadErr && filtered.length === 0 && (
|
||||
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
{tab === 'bottom'
|
||||
? 'No macro-bottom signals yet.'
|
||||
: 'No funding-reversal signals yet.'}
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>
|
||||
{tab === 'bottom' ? 'No bottom signals yet.' : 'No funding signals yet.'}
|
||||
<div style={{ fontSize: 12, marginTop: 6 }}>
|
||||
{tab === 'bottom'
|
||||
? 'This scanner is intentionally rare — only fires at genuine macro bottoms (daily 00:45 UTC).'
|
||||
: 'Fires when 30-day cumulative funding exceeds ±3% AND starts mean-reverting. See the live panel above for current state.'}
|
||||
? 'Intentionally rare — fires only at genuine cycle bottoms.'
|
||||
: 'Check the live panel above for current funding state.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -328,14 +310,10 @@ function FundingPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snap.history && snap.history.length > 1 && (
|
||||
<FundingSparkline
|
||||
history={snap.history}
|
||||
cadenceHours={snap.cadence_hours ?? 8}
|
||||
extremeCumPct={thr}
|
||||
isZh={isZh}
|
||||
/>
|
||||
)}
|
||||
{/* 7-day sparkline removed: the y-range was forced to include the ±threshold
|
||||
lines, so in normal regimes the curve rendered as a flat line on the zero
|
||||
axis with ~80% of the plot being empty danger-zone shading. The stat boxes
|
||||
above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -363,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() {
|
||||
return (
|
||||
<div className="post-stream" style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Instant skeleton while MacroVibesPage fetches indicator data. */
|
||||
export default function MacroLoading() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head" style={{ marginBottom: 24 }}>
|
||||
<div>
|
||||
<div className="skeleton sk-title" style={{ width: 160, marginBottom: 8 }} />
|
||||
<div className="skeleton sk-line" style={{ width: 300 }} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Composite score card */}
|
||||
<div className="skeleton-card" style={{ marginBottom: 16, padding: 24 }}>
|
||||
<div className="skeleton sk-line" style={{ width: 120, marginBottom: 16 }} />
|
||||
<div className="skeleton" style={{ height: 48, width: 200, marginBottom: 16 }} />
|
||||
<div className="skeleton" style={{ height: 24, width: '100%', borderRadius: 12 }} />
|
||||
</div>
|
||||
{/* 8-indicator grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" style={{ padding: 20 }}>
|
||||
<div className="skeleton sk-line sk-w-half" style={{ marginBottom: 12 }} />
|
||||
<div className="skeleton" style={{ height: 36, width: 100, marginBottom: 8 }} />
|
||||
<div className="skeleton sk-line-sm sk-w-3q" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -64,7 +64,10 @@ export async function generateMetadata({
|
||||
|
||||
export default async function MacroVibesPage() {
|
||||
const [posts, fundingSnapshot] = await Promise.all([
|
||||
getPosts(500, 1).catch(() => null),
|
||||
// The default tab is the bottom-reversal view. Fetch only the source this
|
||||
// page actually renders on first paint instead of hydrating with the
|
||||
// latest global post firehose and filtering it client-side.
|
||||
getPosts(200, 1, 'btc_bottom_reversal').catch(() => null),
|
||||
getFundingSnapshot().catch(() => null),
|
||||
])
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ SHORT_INTENT + SHORT_ACTION => ALIGNMENT`,
|
||||
ogDescription:
|
||||
'How each Trump Alpha engine works: data sources, trigger logic, execution rules, and known limitations.',
|
||||
heroTitle: 'Signal Methodology',
|
||||
heroSubtitle: 'How each engine works, from raw input to trading decision',
|
||||
heroSubtitle: 'From raw data to trade decision — how each engine actually works.',
|
||||
intro:
|
||||
'Endorphin runs Trump Alpha as a research project, not a marketing funnel. It is not one monolithic strategy — it is a stack of six independent engines, each with its own data source, trigger logic, and failure mode. This page documents the live production logic, not a simplified pitch.',
|
||||
sections: [
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function generateMetadata({ params }: OverviewPageProps): Promise<M
|
||||
: 'Trump Alpha — Live Crypto Signal Desk'
|
||||
const description = isZh
|
||||
? 'Endorphin 研究台追踪四个先于市场共识的信号:Trump 帖子、BTC 宏观底部、资金费率极值,以及 KOL 言行偏离。每个信号公开且带时间戳。'
|
||||
: 'Endorphin tracks four signals that move crypto before the crowd: Trump posts, BTC macro bottoms, funding-rate extremes, and what KOLs do vs say. Public and timestamped.'
|
||||
: 'Endorphin tracks six signals that move crypto before the crowd: Trump posts, BTC macro bottoms, funding-rate extremes, KOL long-form calls, talks-vs-trades divergence, and the Breakout Monitor. Public and timestamped.'
|
||||
const path = `${siteUrl}/${locale}`
|
||||
|
||||
return {
|
||||
@@ -47,7 +47,9 @@ export async function generateMetadata({ params }: OverviewPageProps): Promise<M
|
||||
}
|
||||
|
||||
export default async function OverviewPage() {
|
||||
const posts = await getPosts(500, 1).catch(() => [])
|
||||
// The overview only renders a small curated slice on first paint; pulling
|
||||
// 500 posts here bloats the server payload without improving the initial UI.
|
||||
const posts = await getPosts(80, 1).catch(() => [])
|
||||
|
||||
return (
|
||||
<DashboardClient
|
||||
|
||||
@@ -1,184 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount } from 'wagmi'
|
||||
import TelegramCard from '@/components/telegram/TelegramCard'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
|
||||
// BotConfigPanel is 867 lines and only needed after wallet connect — lazy load it.
|
||||
import TelegramCard from '@/components/telegram/TelegramCard'
|
||||
|
||||
// BotConfigPanel is heavy and only needed after wallet connect — lazy load it.
|
||||
const BotConfigPanel = dynamic(() => import('@/components/trades/BotConfigPanel'), {
|
||||
ssr: false,
|
||||
loading: () => <div style={{ height: 200, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 16 }} />,
|
||||
})
|
||||
|
||||
/**
|
||||
* Settings page — the home for all configuration.
|
||||
*
|
||||
* Previously the BotConfigPanel lived on /trades (alongside the trade history),
|
||||
* and /settings was just a redirect card. That made /trades double-duty
|
||||
* (configure AND view results) and /settings feel like a dead end. We swap:
|
||||
* - /settings → all configuration (bot, exchange key, subscription)
|
||||
* - /trades → pure execution view (open positions + history)
|
||||
* Legal links stay here since this is the "everything else" page.
|
||||
*/
|
||||
export default function SettingsClient() {
|
||||
const localeIntl = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
const href = (path: string) => `/${locale}${path}`
|
||||
const walletLabel = mounted && isConnected && address
|
||||
? `${address.slice(0, 6)}…${address.slice(-4)}`
|
||||
: 'Not connected'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="settings-control-center">
|
||||
<div>
|
||||
<div className="settings-control-kicker">Control center</div>
|
||||
<div className="settings-control-title">One place to arm, limit, and verify the bot</div>
|
||||
<div className="settings-control-copy">
|
||||
Subscription, execution permissions, per-system risk, and alert delivery all live here.
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-control-meta">
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Wallet</span>
|
||||
<strong className="mono">{walletLabel}</strong>
|
||||
</div>
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Private data</span>
|
||||
<strong>{mounted && isConnected ? 'Ready to unlock' : 'Connect first'}</strong>
|
||||
</div>
|
||||
<div className="settings-meta-card">
|
||||
<span className="settings-meta-label">Main actions</span>
|
||||
<strong>Load settings, save limits, link API</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-intro">
|
||||
<PageHint>
|
||||
Everything that controls your account — subscription, Hyperliquid
|
||||
API key, bot risk parameters, and Telegram alerts.
|
||||
</PageHint>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 8 }}>
|
||||
Trump settings control event-driven entries. Macro Vibes settings control BTC manage-only behavior. Global limits apply account-wide.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-scope-grid" style={{ marginBottom: 16 }}>
|
||||
<section id="trump-settings" className="settings-scope-card trump">
|
||||
<div className="settings-scope-eyebrow">Trump Signal</div>
|
||||
<div className="settings-scope-title">Event-driven entry settings</div>
|
||||
<div className="settings-scope-copy">
|
||||
Position size, Trump leverage, and minimum AI confidence used when a Truth Social post becomes actionable.
|
||||
</div>
|
||||
<a href="#config-trump" className="settings-scope-link">Configure Trump ↓</a>
|
||||
</section>
|
||||
|
||||
<section id="macro-settings" className="settings-scope-card btc">
|
||||
<div className="settings-scope-eyebrow">Macro Vibes</div>
|
||||
<div className="settings-scope-title">BTC manage-only settings</div>
|
||||
<div className="settings-scope-copy">
|
||||
Strategy mode, BTC leverage, and de-risk behavior used by the Macro Vibes sleeve.
|
||||
</div>
|
||||
<a href="#config-macro" className="settings-scope-link">Configure Macro Vibes ↓</a>
|
||||
</section>
|
||||
|
||||
<section id="global-settings" className="settings-scope-card global">
|
||||
<div className="settings-scope-eyebrow">Global</div>
|
||||
<div className="settings-scope-title">Account-wide execution controls</div>
|
||||
<div className="settings-scope-copy">
|
||||
Subscription, Hyperliquid API wallet, manual window, schedule, and guardrails that apply across the account.
|
||||
</div>
|
||||
<a href="#config-global" className="settings-scope-link">Configure global ↓</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Account card — quick "who am I logged in as" */}
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 8 }}>
|
||||
{isZh ? '账户' : 'Account'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: mounted && isConnected ? 'var(--up-soft)' : 'var(--bg-sunk)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: mounted && isConnected ? 'var(--up)' : 'var(--ink-4)', fontSize: 14,
|
||||
}}>
|
||||
{mounted && isConnected ? '✓' : '○'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{mounted && isConnected && address
|
||||
? <span className="mono">{address.slice(0, 10)}…{address.slice(-8)}</span>
|
||||
: (isZh ? '未连接' : 'Not connected')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)' }}>
|
||||
{mounted && isConnected ? (isZh ? '钱包是下方所有配置的身份入口' : 'Wallet is the access key for everything below') : (isZh ? '连接钱包后才能配置机器人' : 'Connect a wallet to configure the bot')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bot-config" className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Execution setup</div>
|
||||
<div className="settings-section-title">Trading permissions and risk controls</div>
|
||||
</div>
|
||||
<div className="settings-section-note">
|
||||
Load once, then adjust by module without leaving the page.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The full bot config UI (subscribe, HL key, risk settings, schedule) */}
|
||||
<div id="bot-config">
|
||||
<BotConfigPanel />
|
||||
</div>
|
||||
|
||||
<div className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Delivery</div>
|
||||
<div className="settings-section-title">Telegram alerts</div>
|
||||
</div>
|
||||
<div className="settings-section-note">
|
||||
Bind notifications after the trading path is configured.
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<TelegramCard />
|
||||
</div>
|
||||
|
||||
<div className="settings-section-shell">
|
||||
<div className="settings-section-head">
|
||||
<div>
|
||||
<div className="settings-section-kicker">Support</div>
|
||||
<div className="settings-section-title">Legal and contact</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal & support */}
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 12 }}>
|
||||
{isZh ? '法律与支持' : 'Legal & support'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Link href={href('/privacy')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '隐私政策 →' : 'Privacy Policy →'}</Link>
|
||||
<Link href={href('/terms')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '服务条款 →' : 'Terms of Service →'}</Link>
|
||||
<Link href={href('/contact')} style={{ fontSize: 13, color: 'var(--ink-2)', textDecoration: 'none' }}>{isZh ? '联系我们 →' : 'Contact Us →'}</Link>
|
||||
</div>
|
||||
<div className="card" style={{ padding: '14px 20px', marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
<Link href={href('/privacy')} style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}>Privacy Policy →</Link>
|
||||
<Link href={href('/terms')} style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}>Terms →</Link>
|
||||
<Link href={href('/contact')} style={{ fontSize: 13, color: 'var(--ink-3)', textDecoration: 'none' }}>Contact →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Instant skeleton while SettingsPage loads. */
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head" style={{ marginBottom: 24 }}>
|
||||
<div className="skeleton sk-title" style={{ width: 120, marginBottom: 8 }} />
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="skeleton-card" style={{ marginBottom: 14, padding: 24 }}>
|
||||
<div className="skeleton sk-line" style={{ width: 160, marginBottom: 18 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div className="skeleton sk-line" style={{ width: 140, marginBottom: 6 }} />
|
||||
<div className="skeleton sk-line-sm" style={{ width: 200 }} />
|
||||
</div>
|
||||
<div className="skeleton" style={{ width: 72, height: 32, borderRadius: 8 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import type { BotTrade, TrumpPost } from '@/types'
|
||||
import { getTrades, getPosts } from '@/lib/api'
|
||||
import type { BotTrade } from '@/types'
|
||||
import { getTrades, getUserPublic } from '@/lib/api'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest'
|
||||
import TradeTable from '@/components/trades/TradeTable'
|
||||
@@ -18,13 +18,13 @@ export default function TradesPageClient() {
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const { address, isConnected } = useAccount()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const { isSubscribed, hlApiKeySet } = useDashboardStore()
|
||||
const { isSubscribed, hlApiKeySet, paperMode,
|
||||
setSubscribed, setHlApiKeySet, setPaperMode, setBotReadiness } = useDashboardStore()
|
||||
const pathname = usePathname()
|
||||
const locale = pathname.split('/')[1] || 'en'
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||||
const [posts, setPosts] = useState<TrumpPost[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||||
@@ -37,41 +37,77 @@ export default function TradesPageClient() {
|
||||
return () => { aliveRef.current = false }
|
||||
}, [])
|
||||
|
||||
// genRef guards loadAll against stale-closure wallet-switch races.
|
||||
// `snapAddr !== address` inside an async function compares two copies of
|
||||
// the same closure-captured value — it's always equal even when the wallet
|
||||
// has changed, so the check is a no-op. genRef is a mutable ref that every
|
||||
// closure can read, so `gen !== genRef.current` correctly detects staleness.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
// B41: TradesPageClient can be the entry point (direct navigation to /trades)
|
||||
// without DashboardClient or BotConfigPanel ever running. In that case the
|
||||
// Zustand store has isSubscribed=false (initial default), causing the
|
||||
// "Bot not configured" banner to appear for valid subscribers.
|
||||
// Fix: fetch /user/{wallet}/public here too — it's lightweight, unauthenticated,
|
||||
// and idempotent with the same fetch DashboardClient does.
|
||||
useEffect(() => {
|
||||
if (!address || !isConnected) return
|
||||
let cancelled = false
|
||||
const snap = address
|
||||
getUserPublic(snap.toLowerCase())
|
||||
.then(u => {
|
||||
if (cancelled || snap !== address) return
|
||||
setSubscribed(u.active)
|
||||
setHlApiKeySet(u.hl_api_key_set)
|
||||
setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setPaperMode(!!u.paper_mode)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, isConnected])
|
||||
|
||||
// Load public posts always; load private trades only if we have a view
|
||||
// envelope. `forcedEnv` lets the in-page Unlock button pass a freshly-minted
|
||||
// one so the user never has to detour through the Settings page first.
|
||||
async function loadAll(forcedEnv?: SignedEnvelope) {
|
||||
if (!address || !isConnected) {
|
||||
setTrades([]); setPosts([]); setLoading(false); setLoadErr(''); setNeedsUnlock(false)
|
||||
setTrades([]); setLoading(false); setLoadErr(''); setNeedsUnlock(false)
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
const snapAddr = address // for API calls only — NOT for stale detection
|
||||
setLoading(true)
|
||||
const env = forcedEnv
|
||||
?? getCachedViewEnvelope('view_trades', address)
|
||||
?? getCachedViewEnvelope('view_user', address)
|
||||
?? getCachedViewEnvelope('view_trades', snapAddr)
|
||||
?? getCachedViewEnvelope('view_user', snapAddr)
|
||||
let failed = false
|
||||
try {
|
||||
const [t, p] = await Promise.all([
|
||||
env
|
||||
? getTrades(address, env, 100, 1).catch(e => {
|
||||
failed = true
|
||||
setLoadErr(e instanceof Error ? e.message : 'Failed to load trades')
|
||||
return [] as BotTrade[]
|
||||
})
|
||||
: Promise.resolve([] as BotTrade[]),
|
||||
getPosts(500, 1).catch(() => [] as TrumpPost[]),
|
||||
])
|
||||
if (!aliveRef.current) return
|
||||
const t = env
|
||||
? await getTrades(snapAddr, env, 500, 1).catch(e => {
|
||||
failed = true
|
||||
setLoadErr(e instanceof Error ? e.message : 'Failed to load trades')
|
||||
return [] as BotTrade[]
|
||||
})
|
||||
: []
|
||||
// Use genRef — NOT `snapAddr !== address` (stale closure: both are the
|
||||
// same closed-over value and the comparison is always false).
|
||||
if (!aliveRef.current || gen !== genRef.current) return
|
||||
setTrades(t)
|
||||
setPosts(p)
|
||||
setNeedsUnlock(!env)
|
||||
if (env && !failed) setLoadErr('')
|
||||
} finally {
|
||||
if (aliveRef.current) setLoading(false)
|
||||
if (aliveRef.current && gen === genRef.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// B27/B35: clear stale previous-wallet data BEFORE the async fetch so the
|
||||
// UI never briefly shows another wallet's private trades.
|
||||
setTrades([])
|
||||
setNeedsUnlock(false)
|
||||
setLoadErr('')
|
||||
// On navigation we only use a cached envelope — never auto-popup the
|
||||
// wallet. If none is cached the user unlocks explicitly via the button.
|
||||
void loadAll()
|
||||
@@ -84,8 +120,12 @@ export default function TradesPageClient() {
|
||||
setUnlocking(true)
|
||||
setLoadErr('')
|
||||
try {
|
||||
// view_user is a superset accepted by /trades, /positions/open,
|
||||
// /positions/today — one signature unlocks everything on this page.
|
||||
// Previously view_trades was used here, but that left OpenPositions
|
||||
// locked (it requires view_positions or view_user).
|
||||
const env = await getOrCreateViewEnvelope({
|
||||
action: 'view_trades', wallet: address, signMessageAsync,
|
||||
action: 'view_user', wallet: address, signMessageAsync,
|
||||
})
|
||||
await loadAll(env)
|
||||
} catch (e) {
|
||||
@@ -97,20 +137,40 @@ export default function TradesPageClient() {
|
||||
}
|
||||
}
|
||||
|
||||
const needsSetup = mounted && isConnected && (!isSubscribed || !hlApiKeySet)
|
||||
// B40: paper users have no HL key but the bot IS configured for them — don't
|
||||
// show "Bot not configured" just because hlApiKeySet is false.
|
||||
// B41: on cold start (navigating directly to /trades), isSubscribed starts
|
||||
// false in the store until DashboardClient or BotConfigPanel fetches
|
||||
// /user/public. Guard with !loading so the banner only appears once
|
||||
// the page has confirmed the wallet is genuinely unconfigured.
|
||||
const needsSetup = mounted && isConnected && !loading &&
|
||||
(!isSubscribed || (!hlApiKeySet && !paperMode))
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
|
||||
{/* Says what the page is, not where things sit — the section cards
|
||||
below are self-labelled ("Open positions", KPI row, table). */}
|
||||
<PageHint>
|
||||
What the bot actually did with your money — currently-open positions
|
||||
on top, closed-trade history with realized P&L below.
|
||||
What the bot holds right now, and every trade it has closed.
|
||||
</PageHint>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
|
||||
dashboard. Show one connect prompt instead and skip the data UI. */}
|
||||
{mounted && !isConnected ? (
|
||||
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||||
Positions and trade history are wallet-bound and private.
|
||||
Use the Connect wallet button in the top-right corner.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{needsSetup && (
|
||||
<div className="card" style={{
|
||||
padding: '14px 18px', marginBottom: 16,
|
||||
@@ -163,7 +223,9 @@ export default function TradesPageClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradeTable trades={trades} posts={posts} loading={loading} />
|
||||
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,10 +34,11 @@ const tradesDataset = {
|
||||
'@id': `${siteUrl}/en/trades#dataset`,
|
||||
name: 'Trump Alpha trade execution history',
|
||||
description:
|
||||
'Record of every signal-triggered trade: asset, direction, entry and exit price, realised P&L, hold time, and the source signal that triggered it. Public and timestamped.',
|
||||
'Per-wallet record of signal-triggered trades: asset, direction, entry and exit price, realised P&L, hold time, and the triggering signal. Private to each wallet owner — wallet signature required to view.',
|
||||
url: `${siteUrl}/en/trades`,
|
||||
keywords: ['crypto trading track record', 'Hyperliquid', 'P&L', 'backtest', 'signal performance'],
|
||||
isAccessibleForFree: true,
|
||||
keywords: ['crypto trading track record', 'Hyperliquid', 'P&L', 'signal performance'],
|
||||
// Data is private (wallet-signed access), not freely accessible to the public
|
||||
isAccessibleForFree: false,
|
||||
creator: { '@type': 'Organization', name: 'Endorphin', url: siteUrl },
|
||||
publisher: { '@id': `${siteUrl}/#org` },
|
||||
license: `${siteUrl}/en/terms`,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import type { TrumpPost } from '@/types'
|
||||
import { getPosts } from '@/lib/api'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import PostRow from '@/components/dashboard/PostCards'
|
||||
import { getPosts, getPostsPage, type PostListResponse } from '@/lib/api'
|
||||
import { hasCached, swrFetch } from '@/lib/cache'
|
||||
import PostRow, { isAiScored } from '@/components/dashboard/PostCards'
|
||||
import SystemControl from '@/components/signals/SystemControl'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
import InfoTip from '@/components/ui/InfoTip'
|
||||
@@ -18,55 +17,147 @@ type SentimentFilter = (typeof SENTIMENTS)[number]
|
||||
type SignalFilter = 'all' | 'actionable' | 'buy' | 'short'
|
||||
|
||||
interface TrumpSignalPageProps {
|
||||
initialPosts?: TrumpPost[] | null
|
||||
initialData?: PostListResponse | null
|
||||
}
|
||||
|
||||
export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPageProps) {
|
||||
const locale = useLocale()
|
||||
const isZh = false
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts ?? [])
|
||||
const [loading, setLoading] = useState(initialPosts === null)
|
||||
const EMPTY_COUNTS: PostListResponse['counts'] = {
|
||||
all: 0,
|
||||
actionable: 0,
|
||||
buy: 0,
|
||||
short: 0,
|
||||
off_topic: 0,
|
||||
}
|
||||
|
||||
function matchesBaseFilters(post: TrumpPost, sentFilter: SentimentFilter, hideNoise: boolean) {
|
||||
if (hideNoise && !isAiScored(post)) return false
|
||||
if (sentFilter !== 'all' && post.sentiment !== sentFilter) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function matchesSignalFilter(post: TrumpPost, sigFilter: SignalFilter) {
|
||||
if (sigFilter === 'actionable') return post.signal === 'buy' || post.signal === 'short'
|
||||
if (sigFilter === 'buy') return post.signal === 'buy'
|
||||
if (sigFilter === 'short') return post.signal === 'short'
|
||||
return true
|
||||
}
|
||||
|
||||
function buildLocalCounts(
|
||||
posts: TrumpPost[],
|
||||
sentFilter: SentimentFilter,
|
||||
hideNoise: boolean,
|
||||
): PostListResponse['counts'] {
|
||||
const sentimentScoped = posts.filter(p => sentFilter === 'all' || p.sentiment === sentFilter)
|
||||
const base = sentimentScoped.filter(p => matchesBaseFilters(p, sentFilter, hideNoise))
|
||||
|
||||
return {
|
||||
all: base.length,
|
||||
actionable: base.filter(p => p.signal === 'buy' || p.signal === 'short').length,
|
||||
buy: base.filter(p => p.signal === 'buy').length,
|
||||
short: base.filter(p => p.signal === 'short').length,
|
||||
off_topic: sentimentScoped.filter(p => !isAiScored(p)).length,
|
||||
}
|
||||
}
|
||||
|
||||
export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageProps) {
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialData?.items ?? [])
|
||||
const [totalPosts, setTotalPosts] = useState(initialData?.total ?? 0)
|
||||
const [counts, setCounts] = useState<PostListResponse['counts']>(initialData?.counts ?? EMPTY_COUNTS)
|
||||
const [loading, setLoading] = useState(initialData === null)
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [sentFilter, setSentFilter] = useState<SentimentFilter>('all')
|
||||
const [sigFilter, setSigFilter] = useState<SignalFilter>('all')
|
||||
const [hideNoise, setHideNoise] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [serverPaging, setServerPaging] = useState(true)
|
||||
|
||||
// Tracks whether we already have any rows on screen, WITHOUT putting
|
||||
// posts.length in the effect deps (which would re-run the effect on every
|
||||
// setPosts and fire a redundant fetch). Updated after each load below.
|
||||
const hasRowsRef = useRef((initialData?.items?.length ?? 0) > 0)
|
||||
|
||||
useEffect(() => {
|
||||
swrFetch(
|
||||
'posts-500',
|
||||
3 * 60_000,
|
||||
() => getPosts(500, 1),
|
||||
fresh => setPosts(fresh),
|
||||
)
|
||||
.then(p => { setPosts(p); setLoadErr('') })
|
||||
.catch(e => setLoadErr(e instanceof Error ? e.message : 'Failed to load posts'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
const filters = {
|
||||
// Don't apply the sentiment bias when a directional signal filter is
|
||||
// active — Buy/Short/Actionable already imply direction, and combining
|
||||
// them with a sentiment filter produces confusing empty results.
|
||||
// sentFilter is intentionally NOT reset when the user switches tabs so
|
||||
// their preference is preserved if they switch back to 'all'.
|
||||
sentiment: sentFilter === 'all' || sigFilter !== 'all' ? undefined : sentFilter,
|
||||
signal: sigFilter === 'all' ? undefined : sigFilter,
|
||||
aiScoredOnly: hideNoise,
|
||||
} as const
|
||||
// sentFilter only participates in the key when sigFilter is 'all' — it's
|
||||
// ignored by the query otherwise, so caching it in the key would create
|
||||
// phantom cache entries that never match on the way back.
|
||||
const effectiveSent = sigFilter === 'all' ? sentFilter : 'all'
|
||||
const key = `posts-truth-page-${page}-sent-${effectiveSent}-sig-${sigFilter}-noise-${hideNoise ? '1' : '0'}`
|
||||
const legacyKey = 'posts-truth-legacy-500'
|
||||
setLoadErr('')
|
||||
setLoading(!hasRowsRef.current && !hasCached(key) && !hasCached(legacyKey))
|
||||
|
||||
const trumpPosts = useMemo(
|
||||
() => posts.filter(p => (p.source || '') === 'truth'),
|
||||
[posts],
|
||||
swrFetch(
|
||||
key,
|
||||
3 * 60_000,
|
||||
() => getPostsPage(PAGE_SIZE, page, 'truth', filters),
|
||||
fresh => {
|
||||
setPosts(fresh.items)
|
||||
setTotalPosts(fresh.total)
|
||||
setCounts(fresh.counts)
|
||||
hasRowsRef.current = fresh.items.length > 0
|
||||
},
|
||||
)
|
||||
.then(r => {
|
||||
setPosts(r.items)
|
||||
setTotalPosts(r.total)
|
||||
setCounts(r.counts)
|
||||
setServerPaging(true)
|
||||
setLoadErr('')
|
||||
hasRowsRef.current = r.items.length > 0
|
||||
})
|
||||
.catch(async e => {
|
||||
const detail = e instanceof Error ? e.message : 'Failed to load posts'
|
||||
if (!detail.includes('404')) {
|
||||
setLoadErr(detail)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Legacy fallback (old backend without /posts-paged). Pull the
|
||||
// largest window the /posts endpoint allows (le=500) so client-side
|
||||
// pagination below has the full set to slice — 200 silently dropped
|
||||
// older matching posts once the truth feed grew past one page.
|
||||
const legacyPosts = await swrFetch(
|
||||
legacyKey,
|
||||
3 * 60_000,
|
||||
() => getPosts(500, 1, 'truth'),
|
||||
)
|
||||
const localCounts = buildLocalCounts(legacyPosts, sentFilter, hideNoise)
|
||||
setPosts(legacyPosts)
|
||||
setTotalPosts(localCounts.all)
|
||||
setCounts(localCounts)
|
||||
setServerPaging(false)
|
||||
setLoadErr('')
|
||||
hasRowsRef.current = legacyPosts.length > 0
|
||||
} catch (legacyErr) {
|
||||
setLoadErr(legacyErr instanceof Error ? legacyErr.message : detail)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [page, sentFilter, sigFilter, hideNoise])
|
||||
|
||||
const filtered = useMemo(
|
||||
() => serverPaging
|
||||
? posts
|
||||
: posts.filter(p => matchesBaseFilters(p, sentFilter, hideNoise) && matchesSignalFilter(p, sigFilter)),
|
||||
[hideNoise, posts, sentFilter, serverPaging, sigFilter],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => trumpPosts.filter(p => {
|
||||
if (sentFilter !== 'all' && p.sentiment !== sentFilter) return false
|
||||
if (sigFilter === 'actionable' && p.signal !== 'buy' && p.signal !== 'short') return false
|
||||
if (sigFilter === 'buy' && p.signal !== 'buy') return false
|
||||
if (sigFilter === 'short' && p.signal !== 'short') return false
|
||||
return true
|
||||
}), [trumpPosts, sentFilter, sigFilter])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||||
const noiseCount = counts.off_topic
|
||||
const totalPages = Math.max(1, Math.ceil(totalPosts / PAGE_SIZE))
|
||||
const safePage = Math.min(page, totalPages)
|
||||
const pageItems = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
|
||||
|
||||
const sigCounts = useMemo(() => ({
|
||||
all: trumpPosts.length,
|
||||
actionable: trumpPosts.filter(p => p.signal === 'buy' || p.signal === 'short').length,
|
||||
buy: trumpPosts.filter(p => p.signal === 'buy').length,
|
||||
short: trumpPosts.filter(p => p.signal === 'short').length,
|
||||
}), [trumpPosts])
|
||||
const pageItems = serverPaging
|
||||
? posts
|
||||
: filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
|
||||
|
||||
const signalLabels: Record<SignalFilter, string> = {
|
||||
all: 'All', actionable: 'Actionable', buy: 'Buy', short: 'Short',
|
||||
@@ -79,10 +170,13 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">① Trump Signal</h1>
|
||||
<PageHint count={`${sigCounts.actionable} actionable / ${trumpPosts.length} posts`}>
|
||||
Watches Trump's Truth Social posts in real time, AI-scores each one,
|
||||
and only fires a trade when the conviction is high enough to move price.
|
||||
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
|
||||
always shows the Auto-Trade feature + its live state. */}
|
||||
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
|
||||
{/* No count slot — the filter tabs right below already show the
|
||||
per-filter numbers (All / Actionable / Buy / Short). */}
|
||||
<PageHint>
|
||||
Every Truth Social post scored in <3s. Trades only when conviction clears the threshold.
|
||||
</PageHint>
|
||||
</div>
|
||||
<span className="chip"><span className="live-dot" />Live</span>
|
||||
@@ -99,45 +193,80 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
|
||||
{([
|
||||
{ key: 'all', hint: 'Every post the scraper has captured.' },
|
||||
{ key: 'actionable', hint: 'Only posts the AI flagged as BUY or SHORT.' },
|
||||
{ key: 'buy', hint: 'Posts where the AI verdict is long BTC.' },
|
||||
{ key: 'short', hint: 'Posts where the AI verdict is short BTC.' },
|
||||
{ key: 'buy', hint: 'Posts the AI scored as a long signal.' },
|
||||
{ key: 'short', hint: 'Posts the AI scored as a short signal.' },
|
||||
] as const).map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`nav-tab ${sigFilter === f.key ? 'active' : ''}`}
|
||||
onClick={() => { setSigFilter(f.key); setPage(1) }}
|
||||
onClick={() => {
|
||||
setSigFilter(f.key)
|
||||
setPage(1)
|
||||
// sentFilter is intentionally NOT reset here — the user's
|
||||
// bias preference is preserved so it's still active when
|
||||
// they switch back to 'all'. The query layer ignores
|
||||
// sentFilter whenever sigFilter is directional.
|
||||
}}
|
||||
>
|
||||
{f.key === 'actionable' ? '🔥 ' : ''}
|
||||
{signalLabels[f.key]}
|
||||
<span style={{ color: 'var(--ink-4)', marginLeft: 4 }}>{sigCounts[f.key]}</span>
|
||||
{signalLabels[f.key]}{' '}
|
||||
<span style={{ color: 'var(--ink-4)' }}>{counts[f.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sentiment filter */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
|
||||
Sentiment
|
||||
<InfoTip
|
||||
text="Filters by the post's overall tone. Sentiment ≠ trade signal — a bearish post can still be a BUY if the market reads it as priced-in."
|
||||
placement="left"
|
||||
/>
|
||||
</span>
|
||||
{SENTIMENTS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setSentFilter(f); setPage(1) }}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
|
||||
background: sentFilter === f ? 'var(--ink)' : 'transparent',
|
||||
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
|
||||
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{sentimentLabels[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Sentiment + noise filters — hidden when a directional filter is
|
||||
already active (actionable / buy / short), because:
|
||||
- "buy" already implies bullish direction
|
||||
- "short" already implies bearish direction
|
||||
- "actionable" = buy ∪ short — sentiment adds no further info
|
||||
Showing them would create confusing combinations (e.g. Buy + Bearish
|
||||
returns 0 results with no explanation). */}
|
||||
{sigFilter === 'all' && (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{/* One-click collapse of off-topic (non-crypto, un-scored) posts.
|
||||
Hidden when a sentiment filter is active — Bullish/Bearish/Neutral
|
||||
results are all AI-scored by definition, so there are no off-topic
|
||||
posts to hide and the button would be meaningless. */}
|
||||
{(noiseCount > 0 || hideNoise) && sentFilter === 'all' && (
|
||||
<button
|
||||
onClick={() => { setHideNoise(v => !v); setPage(1) }}
|
||||
title={`Show only crypto-relevant posts — hides ${noiseCount} off-topic Trump posts`}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--line)',
|
||||
background: hideNoise ? 'var(--ink)' : 'transparent',
|
||||
color: hideNoise ? 'var(--bg)' : 'var(--ink-3)',
|
||||
fontSize: 11, cursor: 'pointer', fontWeight: 600,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{hideNoise ? 'Show all' : 'Signals only'}
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-4)', marginRight: 4 }}>
|
||||
Bias
|
||||
<InfoTip
|
||||
text="Filters by the post's directional bias (bullish / bearish / neutral). Bias ≠ trade signal — a bearish post can still be a BUY if the market reads it as priced-in."
|
||||
placement="left"
|
||||
/>
|
||||
</span>
|
||||
{SENTIMENTS.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => { setSentFilter(f); setPage(1) }}
|
||||
style={{
|
||||
padding: '4px 10px', borderRadius: 6, border: '1px solid var(--line)',
|
||||
background: sentFilter === f ? 'var(--ink)' : 'transparent',
|
||||
color: sentFilter === f ? 'var(--bg)' : 'var(--ink-3)',
|
||||
fontSize: 11, cursor: 'pointer', fontWeight: sentFilter === f ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{sentimentLabels[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <TrumpSkeleton />}
|
||||
@@ -152,7 +281,7 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadErr && filtered.length === 0 && (
|
||||
{!loading && !loadErr && totalPosts === 0 && (
|
||||
<div className="card" style={{ padding: 48, textAlign: 'center', color: 'var(--ink-3)' }}>
|
||||
No Trump signals match the current filter.
|
||||
</div>
|
||||
@@ -167,7 +296,7 @@ export default function TrumpSignalPage({ initialPosts = null }: TrumpSignalPage
|
||||
<Pagination
|
||||
page={safePage}
|
||||
total={totalPages}
|
||||
count={filtered.length}
|
||||
count={totalPosts}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { getPosts } from '@/lib/api'
|
||||
import { type PostListResponse } from '@/lib/api'
|
||||
import type { Metadata } from 'next'
|
||||
import TrumpPageClient from './TrumpPageClient'
|
||||
import Breadcrumbs from '@/components/seo/Breadcrumbs'
|
||||
import { getInitialPostPage } from '@/lib/postPage'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
|
||||
export const revalidate = 30
|
||||
@@ -75,7 +76,10 @@ const trumpDataset = {
|
||||
}
|
||||
|
||||
export default async function TrumpPage() {
|
||||
const posts = await getPosts(500, 1).catch(() => null)
|
||||
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
|
||||
source: 'truth',
|
||||
legacyFallbackSource: 'truth',
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,7 +88,7 @@ export default async function TrumpPage() {
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(trumpDataset) }}
|
||||
/>
|
||||
<Breadcrumbs items={[{ name: 'Trump signals', path: '/en/trump' }]} />
|
||||
<TrumpPageClient initialPosts={posts} />
|
||||
<TrumpPageClient initialData={initialData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+116
-65
@@ -127,17 +127,20 @@
|
||||
}
|
||||
.lp-nav-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px; font-weight: 700;
|
||||
color: #0a0907;
|
||||
background: var(--lp-amber);
|
||||
background: linear-gradient(135deg, #ffd88a, var(--lp-amber) 55%, #ff9a4d);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 4px 16px var(--lp-amber-glow), inset 0 1px 0 rgba(255,255,255,0.3);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.lp-nav-cta:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 30px var(--lp-amber-glow);
|
||||
box-shadow: 0 10px 30px var(--lp-amber-glow), inset 0 1px 0 rgba(255,255,255,0.3);
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
@@ -175,15 +178,16 @@
|
||||
.lp-live-badge {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
margin: 0 0 22px;
|
||||
padding: 5px 12px 5px 9px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
color: var(--lp-ink-3);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 6px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--lp-ink-2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
animation: lp-fade-up 0.7s ease both;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
}
|
||||
.lp-live-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
@@ -338,16 +342,17 @@
|
||||
.lp-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--lp-amber);
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 0 20px rgba(245,165,36,0.35);
|
||||
}
|
||||
.lp-h2 {
|
||||
font-size: clamp(26px, 3.8vw, 48px);
|
||||
font-size: clamp(26px, 3.8vw, 52px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.lp-lead {
|
||||
@@ -410,9 +415,10 @@
|
||||
.lp-step-num {
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--lp-ink-4);
|
||||
color: var(--lp-amber);
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.lp-step-icon {
|
||||
width: 52px; height: 52px;
|
||||
@@ -425,13 +431,13 @@
|
||||
}
|
||||
.lp-step h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.lp-step p {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.62;
|
||||
color: var(--lp-ink-3);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -450,30 +456,47 @@
|
||||
background: var(--lp-surface);
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 16px;
|
||||
transition: all 0.2s ease;
|
||||
transition: transform 0.22s ease, border-color 0.22s ease, background 0.22s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lp-feat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(245,165,36,0.4), transparent 55%);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
.lp-feat:hover {
|
||||
background: var(--lp-surface-2);
|
||||
border-color: var(--lp-line-2);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(245,165,36,0.22);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.lp-feat:hover::before { opacity: 1; }
|
||||
.lp-feat-icon {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 11px;
|
||||
display: grid; place-items: center;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(245,165,36,0.1);
|
||||
background: linear-gradient(135deg, rgba(245,165,36,0.18), rgba(245,165,36,0.06));
|
||||
border: 1px solid rgba(245,165,36,0.18);
|
||||
color: var(--lp-amber);
|
||||
}
|
||||
.lp-feat h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.01em;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.lp-feat p {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
line-height: 1.58;
|
||||
color: var(--lp-ink-3);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -575,7 +598,7 @@
|
||||
|
||||
/* ---------- Final CTA ---------- */
|
||||
.lp-cta-final {
|
||||
padding: 88px 0 96px;
|
||||
padding: 96px 0 104px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
@@ -583,10 +606,10 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%; top: 50%;
|
||||
width: 700px; height: 700px;
|
||||
background: radial-gradient(circle, rgba(245,165,36,0.18), transparent 60%);
|
||||
width: 800px; height: 700px;
|
||||
background: radial-gradient(ellipse, rgba(245,165,36,0.22) 0%, rgba(239,68,68,0.10) 45%, transparent 70%);
|
||||
transform: translate(-50%, -50%);
|
||||
filter: blur(80px);
|
||||
filter: blur(90px);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -646,23 +669,25 @@
|
||||
}
|
||||
.lp-stat-val {
|
||||
font-family: 'Geist', sans-serif;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
color: var(--lp-ink);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.lp-stat-val .suffix {
|
||||
font-size: 18px;
|
||||
font-size: 20px;
|
||||
color: var(--lp-amber);
|
||||
margin-left: 2px;
|
||||
margin-left: 3px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lp-stat-lbl {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--lp-ink-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.lp-stat-sub {
|
||||
font-size: 11px;
|
||||
@@ -681,17 +706,18 @@
|
||||
|
||||
.lp-post-card {
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01));
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.045), rgba(255,255,255,0.01));
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
transition: all 0.25s ease;
|
||||
transition: transform 0.22s ease, border-color 0.22s ease;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
.lp-post-card:hover {
|
||||
border-color: var(--lp-line-2);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(245,165,36,0.2);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.lp-post-kind {
|
||||
width: 44px; height: 44px; flex-shrink: 0;
|
||||
@@ -891,11 +917,12 @@
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
margin-top: 48px;
|
||||
padding: 32px 16px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.005));
|
||||
padding: 36px 20px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01));
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
@media (max-width: 880px) { .lp-flow { grid-template-columns: 1fr; gap: 20px; } }
|
||||
|
||||
@@ -907,11 +934,12 @@
|
||||
.lp-flow-step:not(:last-child)::after {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 28px;
|
||||
font-size: 20px;
|
||||
right: -10px;
|
||||
top: 26px;
|
||||
font-size: 22px;
|
||||
color: var(--lp-amber);
|
||||
opacity: 0.6;
|
||||
opacity: 0.7;
|
||||
text-shadow: 0 0 14px rgba(245,165,36,0.5);
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.lp-flow-step:not(:last-child)::after { display: none; }
|
||||
@@ -919,15 +947,16 @@
|
||||
|
||||
.lp-flow-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 56px; height: 56px;
|
||||
width: 58px; height: 58px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(245,165,36,0.2), rgba(245,165,36,0.05));
|
||||
border: 1px solid rgba(245,165,36,0.3);
|
||||
background: linear-gradient(135deg, rgba(245,165,36,0.22), rgba(245,165,36,0.06));
|
||||
border: 1px solid rgba(245,165,36,0.35);
|
||||
color: var(--lp-amber);
|
||||
font-weight: 700; font-size: 18px;
|
||||
font-weight: 800; font-size: 18px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
.lp-flow-badge::before {
|
||||
content: '';
|
||||
@@ -938,10 +967,11 @@
|
||||
z-index: -1;
|
||||
}
|
||||
.lp-flow-step h5 {
|
||||
font-size: 14px; font-weight: 600; margin: 0 0 6px;
|
||||
font-size: 14px; font-weight: 700; margin: 0 0 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.lp-flow-step p {
|
||||
font-size: 12px; line-height: 1.5;
|
||||
font-size: 12px; line-height: 1.55;
|
||||
color: var(--lp-ink-3); margin: 0;
|
||||
}
|
||||
.lp-flow-time {
|
||||
@@ -969,11 +999,12 @@
|
||||
background: var(--lp-surface);
|
||||
border: 1px solid var(--lp-line);
|
||||
border-radius: 14px;
|
||||
transition: all 0.2s ease;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.lp-faq-item:hover {
|
||||
background: var(--lp-surface-2);
|
||||
border-color: var(--lp-line-2);
|
||||
border-color: rgba(245,165,36,0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.lp-faq-q {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
@@ -1686,12 +1717,18 @@
|
||||
animation: lp-fade-up 0.9s 0.55s ease both;
|
||||
}
|
||||
.lp-engine-chip {
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 4px 10px; border-radius: 5px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
padding: 5px 11px; border-radius: 6px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.06);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.lp-engine-chip:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.lp-engine-chip.trump { color: var(--lp-amber); background: rgba(245,165,36,0.10); border-color: rgba(245,165,36,0.28); }
|
||||
.lp-engine-chip.macro { color: var(--lp-green); background: rgba(31,219,99,0.09); border-color: rgba(31,219,99,0.25); }
|
||||
@@ -1715,12 +1752,12 @@
|
||||
}
|
||||
.lp-livefeed-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 11px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--lp-ink-3);
|
||||
background: rgba(255,255,255,0.015);
|
||||
letter-spacing: 0.13em; text-transform: uppercase;
|
||||
color: var(--lp-ink-2);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01));
|
||||
}
|
||||
.lp-livefeed-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
@@ -1968,10 +2005,11 @@
|
||||
padding-top: 12px; border-top: 1px solid var(--lp-line);
|
||||
}
|
||||
.lp-pillar-metric {
|
||||
font-size: 22px; font-weight: 700;
|
||||
font-size: 26px; font-weight: 800;
|
||||
color: var(--lp-amber);
|
||||
font-family: 'Geist Mono', ui-monospace, monospace;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.lp-pillar-metric-lbl {
|
||||
font-size: 11px; color: var(--lp-ink-4);
|
||||
@@ -1985,10 +2023,21 @@
|
||||
|
||||
/* reduce motion respect */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Catch-all: neutralise all 14 infinite animations (lp-scroll marquee,
|
||||
lp-float blobs, lp-ping/lp-pulse dots, lp-blink cursor, …) plus every
|
||||
entrance animation. 0.01ms keeps onAnimationEnd handlers firing. */
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.lp-noise,
|
||||
.lp-pillar-edge { animation: none !important; }
|
||||
.lp-pillar { transform: none !important; transition: none; }
|
||||
.lp-spotlight { display: none; }
|
||||
/* Marquee: park at origin instead of mid-translate(-50%). */
|
||||
.lp-ticker-track { animation: none !important; transform: none !important; }
|
||||
}
|
||||
|
||||
/* ── FAQ ──────────────────────────────────────────────── */
|
||||
@@ -2006,7 +2055,9 @@
|
||||
background: var(--lp-surface);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.lp-faq-item:hover { border-color: var(--lp-line-2); }
|
||||
.lp-faq-item:hover {
|
||||
border-color: rgba(245,165,36,0.28);
|
||||
}
|
||||
.lp-faq-q {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
+6
-6
@@ -5,7 +5,7 @@ import './[locale]/globals.css'
|
||||
|
||||
const siteTitle = 'Trump Alpha'
|
||||
const siteTagline = 'The crypto signals that move price before the crowd'
|
||||
const siteDescription = "Endorphin is a crypto research desk tracking four signals that move markets before consensus catches up — Trump's posts, BTC macro bottoms, funding-rate extremes, and what KOLs do versus what they say. Every signal is public and timestamped."
|
||||
const siteDescription = "Endorphin is a crypto research desk tracking six signals that move markets before consensus catches up — Trump's posts, BTC macro bottoms, funding-rate extremes, KOL long-form calls, talks-vs-trades divergence, and the Breakout Monitor. Every signal is public and timestamped."
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -283,7 +283,7 @@ const jsonLd = {
|
||||
name: 'What is Trump Alpha?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Trump Alpha is an AI-powered crypto intelligence dashboard that monitors four signal sources: Trump Truth Social posts, BTC macro-bottom confluence signals, KOL Substack and podcast essays, and talks-vs-trades divergence between public commentary and on-chain behavior.',
|
||||
text: 'Trump Alpha is an AI-powered crypto intelligence dashboard that monitors six signal sources: Trump Truth Social posts, BTC macro-bottom confluence signals, KOL Substack and podcast essays, talks-vs-trades divergence between public commentary and on-chain behavior, a funding-rate reversal trigger, and a real-time breakout monitor for ETH and LINK.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -315,7 +315,7 @@ const jsonLd = {
|
||||
name: 'Which KOLs does Trump Alpha track?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Trump Alpha tracks 19 KOL feeds including Arthur Hayes (BitMEX), Delphi Digital, Dragonfly Capital, Pomp, and major crypto podcasts (Empire, Bankless, Unchained, 0xResearch, Lightspeed). Posts and episodes are processed daily; AI extracts ticker calls, direction (bullish/bearish/buy/sell), and conviction scores.',
|
||||
text: 'Trump Alpha tracks 25 KOL feeds including Arthur Hayes (BitMEX), Delphi Digital, Pomp, and major crypto podcasts (Empire, Bankless, Unchained, 0xResearch, Lightspeed). Posts and episodes are processed daily; AI extracts ticker calls, direction (bullish/bearish/buy/sell), and conviction scores.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -323,7 +323,7 @@ const jsonLd = {
|
||||
name: 'Is Trump Alpha free?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Yes. All four signal dashboards (Trump, Macro Vibes, KOL, talks-vs-trades) are free to read. The optional Hyperliquid auto-trader requires your own Hyperliquid account and API key. Trump Alpha never takes custody of your funds — your API key can open and close positions but cannot withdraw.',
|
||||
text: 'Yes. All six signal dashboards are free to read. The optional Hyperliquid auto-trader requires a subscription, your own Hyperliquid account, and an API key. Trump Alpha never takes custody of your funds — your API key can open and close positions but cannot withdraw.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -380,7 +380,7 @@ const jsonLd = {
|
||||
name: 'What crypto signals do KOL newsletters give?',
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: 'Trump Alpha ingests 19 crypto KOL newsletters, blogs, and podcast feeds daily — including Arthur Hayes, Delphi Digital, Dragonfly Capital, Bankless, and Unchained. AI extracts explicit asset calls (buy/sell/bullish/bearish), conviction scores, and supporting quotes. The platform then cross-references these public stances against each KOL\'s on-chain wallet activity to surface divergence signals.',
|
||||
text: 'Trump Alpha ingests 25 crypto KOL newsletters, blogs, and podcast feeds daily — including Arthur Hayes, Delphi Digital, Bankless, and Unchained. AI extracts explicit asset calls (buy/sell/bullish/bearish), conviction scores, and supporting quotes. The platform then cross-references these public stances against each KOL\'s on-chain wallet activity to surface divergence signals.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -429,7 +429,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale === 'zh' ? 'zh-CN' : 'en'}>
|
||||
<html lang={locale === 'zh' ? 'zh-CN' : 'en-US'}>
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function OGImage() {
|
||||
>
|
||||
{[
|
||||
{ v: '<3s', l: 'post → position' },
|
||||
{ v: '19', l: 'KOL feeds' },
|
||||
{ v: '25', l: 'KOL feeds' },
|
||||
{ v: '$0', l: 'platform fee' },
|
||||
].map((s) => (
|
||||
<div key={s.l} style={{ display: 'flex', gap: 10, alignItems: 'baseline' }}>
|
||||
|
||||
+7
-7
@@ -354,8 +354,8 @@ export default function LandingPage() {
|
||||
<PillarCard
|
||||
tag="Daily · narrative"
|
||||
title="KOL Signal"
|
||||
desc="19 crypto KOL feeds (Hayes, Delphi, Bankless…). AI extracts which tokens they called and how convicted they were."
|
||||
metric="15"
|
||||
desc="25 crypto KOL feeds (Hayes, Delphi, Bankless…). AI extracts which tokens they called and how convicted they were."
|
||||
metric="25"
|
||||
metricLabel="live feeds"
|
||||
href="/en/kol"
|
||||
/>
|
||||
@@ -621,8 +621,8 @@ export default function LandingPage() {
|
||||
<div className="lp-reveal" style={{ maxWidth: 680 }}>
|
||||
<div className="lp-eyebrow">How it compares</div>
|
||||
<h2 className="lp-h2">
|
||||
Free. Non-custodial.<br />
|
||||
<span className="grad">No subscription.</span>
|
||||
Free to read. Non-custodial.<br />
|
||||
<span className="grad">Auto-trade is opt-in.</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="lp-compare-wrap lp-reveal">
|
||||
@@ -638,7 +638,7 @@ export default function LandingPage() {
|
||||
<tbody>
|
||||
<CompRow f="Trump post → trade latency" us="<3 seconds" them="manual / N/A" manual="minutes–hours" />
|
||||
<CompRow f="BTC macro-bottom signal" us="✓ 2-of-3 confluence" them="paid tier" manual="✗" />
|
||||
<CompRow f="KOL feed ingestion" us="19 feeds daily" them="limited / paid" manual="slow" />
|
||||
<CompRow f="KOL feed ingestion" us="25 feeds daily" them="limited / paid" manual="slow" />
|
||||
<CompRow f="Talks-vs-trades divergence" us="✓ built-in" them="✗" manual="✗" />
|
||||
<CompRow f="Auto-trader integration" us="✓ Hyperliquid" them="✗" manual="✗" />
|
||||
<CompRow f="Price" us="Free" them="$49–$299/mo" manual="time cost" />
|
||||
@@ -661,8 +661,8 @@ export default function LandingPage() {
|
||||
<strong>Trump Alpha</strong> is an AI-powered crypto intelligence dashboard that
|
||||
aggregates six uncorrelated signal sources into one live feed — Trump Truth Social
|
||||
posts, BTC macro-bottom confluence (AHR999 + 200-week MA + Pi Cycle Bottom),
|
||||
19 KOL narrative feeds, talks-vs-trades divergence, and a
|
||||
Hyperliquid auto-trader. All signal reading is free. No custody. No subscription.
|
||||
25 KOL narrative feeds, talks-vs-trades divergence, and a
|
||||
Hyperliquid auto-trader. All signal reading is free and public. No custody. Auto-trading requires a subscription and your own HL API key.
|
||||
</p>
|
||||
<p className="lp-lead" style={{ marginBottom: 0, fontSize: 15 }}>
|
||||
<Link href="/en/methodology" style={{ color: 'var(--lp-amber)', textDecoration: 'none' }}>Signal methodology</Link>
|
||||
|
||||
@@ -128,11 +128,14 @@ function MetricCard({
|
||||
activeIndex,
|
||||
chartHref,
|
||||
chartLabel = 'CoinGlass',
|
||||
hero = false,
|
||||
}: {
|
||||
rank: number
|
||||
label: string
|
||||
value: string
|
||||
tone?: Tone
|
||||
/** What the indicator IS, one sentence. Do NOT enumerate threshold bands
|
||||
* here — the chips below each card already list them. */
|
||||
hint: string
|
||||
summary: string
|
||||
thresholds: Array<{ label: string; tone?: Tone }>
|
||||
@@ -143,31 +146,54 @@ function MetricCard({
|
||||
* the dual "Data source + Chart" pattern confused users about which to click. */
|
||||
chartHref: string
|
||||
chartLabel?: string
|
||||
/** Horizontal headline layout for a single full-width card (Valuation).
|
||||
* The stacked layout stretched across the panel left a dead middle. */
|
||||
hero?: boolean
|
||||
}) {
|
||||
const head = (
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
)
|
||||
const chips = (
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</ThresholdChip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const action = (
|
||||
<div className="macro-actions">
|
||||
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (hero) {
|
||||
return (
|
||||
<article className={`macro-metric-card tone-${tone} hero`}>
|
||||
{head}
|
||||
<div className="macro-hero-body">
|
||||
<div className="macro-summary">{summary}</div>
|
||||
{chips}
|
||||
</div>
|
||||
{action}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`macro-metric-card tone-${tone}`}>
|
||||
<div className="macro-metric-head">
|
||||
<div className="macro-metric-title">
|
||||
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
|
||||
<span>{label}</span>
|
||||
<InfoTip text={hint} placement="top" width={280} />
|
||||
</div>
|
||||
<div className="macro-metric-value">{value}</div>
|
||||
</div>
|
||||
|
||||
{head}
|
||||
<div className="macro-summary">{summary}</div>
|
||||
|
||||
<div className="macro-thresholds">
|
||||
{thresholds.map((item, i) => (
|
||||
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
|
||||
{item.label}
|
||||
</ThresholdChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="macro-actions">
|
||||
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
|
||||
</div>
|
||||
{chips}
|
||||
{action}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -402,11 +428,12 @@ export default function MacroPanel() {
|
||||
<Section title="Valuation">
|
||||
<div className="macro-grid one">
|
||||
<MetricCard
|
||||
hero
|
||||
rank={1}
|
||||
label="AHR999"
|
||||
value={ind.ahr999?.toFixed(4) ?? '—'}
|
||||
tone={toneAhr(ind.ahr999)}
|
||||
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.45–0.75 = fair value leaning cheap, 0.75–1.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}
|
||||
activeIndex={ahrActive}
|
||||
thresholds={[
|
||||
@@ -457,7 +484,7 @@ export default function MacroPanel() {
|
||||
label="Altcoin Season"
|
||||
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
|
||||
tone={toneAltseason(ind.altcoin_season_index)}
|
||||
hint="% of top-50 alts that beat BTC over the last 30 days. < 25 = Bitcoin season, 25–60 = mixed, 60–75 = 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}
|
||||
activeIndex={altActive}
|
||||
thresholds={[
|
||||
@@ -466,15 +493,15 @@ export default function MacroPanel() {
|
||||
{ label: '60–75 alt strength', tone: 'up' },
|
||||
{ label: '≥ 75 altseason', tone: 'up' },
|
||||
]}
|
||||
chartHref="https://www.coinglass.com/en/pro/i/alt-coin-season"
|
||||
chartLabel="CoinGlass"
|
||||
chartHref="https://www.blockchaincenter.net/altcoin-season-index/"
|
||||
chartLabel="BlockchainCenter"
|
||||
/>
|
||||
<MetricCard
|
||||
rank={3}
|
||||
label="BTC Dominance"
|
||||
value={fmtPct(ind.btc_dominance_pct)}
|
||||
tone={toneBtcDominance(ind.btc_dominance_pct)}
|
||||
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 45–55% = balanced, 55–65% = 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}
|
||||
activeIndex={domActive}
|
||||
thresholds={[
|
||||
@@ -491,7 +518,7 @@ export default function MacroPanel() {
|
||||
label="ETH / BTC"
|
||||
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
|
||||
tone={toneEthBtc(ind.eth_btc_ratio)}
|
||||
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.025–0.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}
|
||||
activeIndex={ebrActive}
|
||||
thresholds={[
|
||||
@@ -547,7 +574,7 @@ export default function MacroPanel() {
|
||||
label="ETF Net Flow (1d)"
|
||||
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
|
||||
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
|
||||
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$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}
|
||||
activeIndex={etfActive}
|
||||
thresholds={[
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
const ACTION_SET_API_KEY = 'set_hl_api_key'
|
||||
const ACTION_SUBSCRIBE = 'subscribe'
|
||||
|
||||
interface Props {
|
||||
performance?: BotPerformance | null
|
||||
}
|
||||
|
||||
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
|
||||
|
||||
function fmtHold(s: number) {
|
||||
if (s < 60) return s + 's'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return m + 'm'
|
||||
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'
|
||||
}
|
||||
|
||||
export default function BotPanel({ performance }: Props) {
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connectAsync, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
|
||||
const [subError, setSubError] = useState('')
|
||||
const [connectError, setConnectError] = useState('')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
return
|
||||
}
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
getUserPublic(address.toLowerCase())
|
||||
.then((user) => {
|
||||
setSubscribed(user.active)
|
||||
setHlApiKeySet(user.hl_api_key_set)
|
||||
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [address, isConnected, setHlApiKeySet, setSubscribed, setBotReadiness])
|
||||
|
||||
async function refreshUserState(wallet: string) {
|
||||
const pub = await getUserPublic(wallet.toLowerCase())
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
return pub
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!address) return
|
||||
setSubError('')
|
||||
try {
|
||||
setSubState('signing')
|
||||
const env = await signRequest({
|
||||
action: ACTION_SUBSCRIBE,
|
||||
wallet: address,
|
||||
body: null,
|
||||
signMessageAsync,
|
||||
})
|
||||
setSubState('saving')
|
||||
await subscribe(env)
|
||||
await refreshUserState(address)
|
||||
setSubState('idle')
|
||||
} catch (err: unknown) {
|
||||
setSubError(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
setSubState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveKey() {
|
||||
if (!address || !apiKey.trim()) return
|
||||
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
|
||||
setErrorMsg('Key must start with 0x and be 66 characters')
|
||||
setSaveState('error')
|
||||
return
|
||||
}
|
||||
setErrorMsg('')
|
||||
try {
|
||||
setSaveState('signing')
|
||||
const trimmed = apiKey.trim()
|
||||
const env = await signRequest({
|
||||
action: ACTION_SET_API_KEY,
|
||||
wallet: address,
|
||||
body: { api_key: trimmed },
|
||||
signMessageAsync,
|
||||
})
|
||||
setSaveState('saving')
|
||||
const res = await setHlApiKey(env, trimmed)
|
||||
const pub = await refreshUserState(address)
|
||||
setHlApiKeySet(pub.hl_api_key_set, res.masked_key)
|
||||
setApiKey('')
|
||||
setSaveState('success')
|
||||
} catch (err: unknown) {
|
||||
if (isUserRejection(err)) {
|
||||
setErrorMsg('Signature cancelled')
|
||||
} else {
|
||||
setErrorMsg(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
}
|
||||
setSaveState('error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel =
|
||||
saveState === 'signing' ? 'Waiting for signature…'
|
||||
: saveState === 'saving' ? 'Saving…'
|
||||
: saveState === 'success' ? '✓ Saved'
|
||||
: 'Save key'
|
||||
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
} catch (err: unknown) {
|
||||
setConnectError(walletConnectErrorLabel(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bot status card — dark design */}
|
||||
<div className="bot-status">
|
||||
<div className="bot-head">
|
||||
<h3>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: botReadiness === 'ready' ? 'var(--amber)' : hlApiKeySet ? 'var(--up)' : isSubscribed ? 'var(--up)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
|
||||
Auto-trader
|
||||
</h3>
|
||||
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
|
||||
</div>
|
||||
|
||||
<div className="bot-stats">
|
||||
<div className="bot-stat">
|
||||
<div className="k">Net P&L</div>
|
||||
<div className="v amber">
|
||||
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString('en-US', { maximumFractionDigits: 0 }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Win rate</div>
|
||||
<div className="v up">
|
||||
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Trades</div>
|
||||
<div className="v">{performance?.total_trades ?? '—'}</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Avg hold</div>
|
||||
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bot-cta">
|
||||
{(!mounted || !isConnected) && (
|
||||
<>
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={() => { void handleConnectWallet() }}>
|
||||
Connect wallet
|
||||
</button>
|
||||
{connectError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{connectError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mounted && isConnected && !isSubscribed && (
|
||||
<div style={{ width: '100%' }}>
|
||||
<button
|
||||
className="btn amber"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSubscribe}
|
||||
disabled={subState === 'signing' || subState === 'saving'}
|
||||
>
|
||||
{subState === 'signing' ? 'Waiting for signature…' : subState === 'saving' ? 'Activating…' : 'Start trading'}
|
||||
</button>
|
||||
{subState === 'error' && subError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{subError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mounted && isConnected && isSubscribed && !hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
|
||||
↓ Paste your Hyperliquid API key below to finish setup
|
||||
</div>
|
||||
)}
|
||||
{mounted && isConnected && isSubscribed && hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
|
||||
✓ Setup saved · verification still depends on backend
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HL API Key card — only when subscribed */}
|
||||
{mounted && isConnected && isSubscribed && (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div className="section-title">
|
||||
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
|
||||
{hlApiKeySet && <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 500 }}>✓ Connected</span>}
|
||||
</div>
|
||||
|
||||
{hlApiKeySet && !apiKey && (
|
||||
<div className="row between" style={{ padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)', border: '1px solid var(--line)', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--up)', fontFamily: 'var(--mono)' }}>
|
||||
{hlApiKeyMasked ?? '···'}
|
||||
</span>
|
||||
<button
|
||||
style={{ fontSize: 11, color: 'var(--ink-3)' }}
|
||||
onClick={() => { setSaveState('idle'); setApiKey(' ') }}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!hlApiKeySet || apiKey) && (
|
||||
<>
|
||||
<div className="field" style={{ marginBottom: 12 }}>
|
||||
<label>API wallet private key</label>
|
||||
<input
|
||||
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
if (saveState === 'error' || saveState === 'success') setSaveState('idle')
|
||||
}}
|
||||
placeholder="0x…"
|
||||
/>
|
||||
<div className="hint">
|
||||
From{' '}
|
||||
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber-ink)' }}>
|
||||
app.hyperliquid.xyz/API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState === 'error' && errorMsg && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginBottom: 8 }}>{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn ${saveState === 'success' ? 'ghost' : 'amber'}`}
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSaveKey}
|
||||
disabled={saveState === 'signing' || saveState === 'saving' || !apiKey.trim()}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hlApiKeySet && (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ fontSize: 11, color: 'var(--ink-3)', cursor: 'pointer' }}>
|
||||
How to get your API key
|
||||
</summary>
|
||||
<ol style={{ marginTop: 8, fontSize: 11, color: 'var(--ink-3)', paddingLeft: 16, lineHeight: 1.7 }}>
|
||||
<li>Deposit USDC at app.hyperliquid.xyz</li>
|
||||
<li>Go to app.hyperliquid.xyz/API</li>
|
||||
<li>Click <strong>Generate API Wallet</strong></li>
|
||||
<li>Sign with MetaMask (no gas)</li>
|
||||
<li>Copy the private key → paste above</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { TrumpPost, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
|
||||
@@ -33,6 +33,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
|
||||
const repositionMacroRef = useRef<(() => void) | null>(null)
|
||||
const fittedRef = useRef(false)
|
||||
// Chart creation is async (dynamic import). If the candles fetch wins the
|
||||
// race (common — SWR serves them instantly from cache), the data effect
|
||||
// runs while seriesRef is still null and bails; nothing re-triggers it
|
||||
// afterwards, so the chart stays EMPTY and the 2s live-tick effect then
|
||||
// paints a single lone candle via series.update(). This state flips when
|
||||
// the chart actually exists so the data effect re-runs.
|
||||
const [chartReady, setChartReady] = useState(false)
|
||||
// The live-tick effect must never run before setData() — updating an empty
|
||||
// series creates a one-candle chart.
|
||||
const dataLoadedRef = useRef(false)
|
||||
const postsRef = useRef(posts)
|
||||
postsRef.current = posts
|
||||
const selectedPostIdRef = useRef(externalSelectedId)
|
||||
@@ -111,7 +121,11 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
const chart = createChart(containerRef.current, {
|
||||
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: {
|
||||
background: { color: colors.background },
|
||||
textColor: colors.textColor,
|
||||
@@ -145,6 +159,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
seriesRef.current = series
|
||||
|
||||
// Breathing room above/below the candles. Markers render ~10px outside
|
||||
// the bar's high/low; with the default tight bottom margin a crash
|
||||
// candle at the bottom of the autoscaled range gets its marker dots
|
||||
// clipped by the pane edge.
|
||||
series.priceScale().applyOptions({
|
||||
scaleMargins: { top: 0.12, bottom: 0.16 },
|
||||
})
|
||||
|
||||
setChartReady(true)
|
||||
|
||||
// Re-project macro signal time → pixel X on every visible-range
|
||||
// change (pan / zoom / data update / resize). Without this the line
|
||||
// is set ONCE in the data effect and then stays stuck at a fixed
|
||||
@@ -226,7 +250,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
ro = new ResizeObserver(() => {
|
||||
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
|
||||
// marker too, otherwise it'd drift relative to the candles on
|
||||
// window resize / sidebar toggle.
|
||||
@@ -269,6 +296,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
ro?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
fittedRef.current = false
|
||||
dataLoadedRef.current = false
|
||||
setChartReady(false)
|
||||
if (chartRef.current) {
|
||||
// @ts-expect-error lightweight-charts type
|
||||
chartRef.current.remove()
|
||||
@@ -291,7 +320,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
useEffect(() => {
|
||||
const series = seriesRef.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)
|
||||
|
||||
@@ -303,6 +335,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
})))
|
||||
dataLoadedRef.current = true
|
||||
|
||||
const minTime = sorted[0].time
|
||||
const maxTime = sorted[sorted.length - 1].time
|
||||
@@ -313,6 +346,13 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return t >= minTime && t <= maxTime
|
||||
})
|
||||
|
||||
// Markers show actionable signals only. Hold/filtered posts vastly
|
||||
// outnumber signals and their gray dots pile into unreadable clusters;
|
||||
// they stay browsable in the Recent-signals list instead.
|
||||
const markerPosts = visible.filter(
|
||||
(p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell',
|
||||
)
|
||||
|
||||
const bucketByTf: Record<string, number> = {
|
||||
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
|
||||
}
|
||||
@@ -320,7 +360,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
|
||||
|
||||
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 bucket = Math.floor(pt / bucketSecs) * bucketSecs
|
||||
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
|
||||
@@ -415,9 +455,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
chart.timeScale().fitContent()
|
||||
fittedRef.current = true
|
||||
}
|
||||
}, [candles, posts, externalSelectedId, asset])
|
||||
}, [candles, posts, externalSelectedId, asset, chartReady])
|
||||
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe])
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe, asset])
|
||||
|
||||
// Live-tick the rightmost candle so the chart feels alive between REST polls.
|
||||
// lightweight-charts' `series.update()` either appends a new bar (newer time)
|
||||
@@ -426,7 +466,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
// the live tick exceeds them.
|
||||
useEffect(() => {
|
||||
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]
|
||||
if (live == null || !candles.length) return
|
||||
const last = candles[candles.length - 1]
|
||||
@@ -442,7 +484,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
return (
|
||||
<div
|
||||
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' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import type { TrumpPost } from '@/types'
|
||||
|
||||
/**
|
||||
* Was this post actually AI-scored, or filtered as off-topic noise before any
|
||||
* AI call? Single source of truth — used both by the card (to de-emphasise
|
||||
* noise) and by list views (to offer a "collapse off-topic" toggle), so the
|
||||
* two can never disagree about what counts as noise.
|
||||
*/
|
||||
export function isAiScored(post: Pick<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
|
||||
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
|
||||
}
|
||||
|
||||
function fmtPct(n: number | null | undefined) {
|
||||
if (n == null || isNaN(n)) return '—' // null = window not yet closed
|
||||
const s = n.toFixed(2) + '%'
|
||||
@@ -56,16 +66,18 @@ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatO
|
||||
// When adding a new scanner source, register it here too — otherwise it
|
||||
// falls through to the generic "first letter" fallback which has no title
|
||||
// or accent colour.
|
||||
const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string }> = {
|
||||
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' },
|
||||
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
|
||||
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
|
||||
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' },
|
||||
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' },
|
||||
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' },
|
||||
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' },
|
||||
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' },
|
||||
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry' },
|
||||
export const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string; label: string }> = {
|
||||
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social', label: '@realDonaldTrump' },
|
||||
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
|
||||
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
|
||||
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner', label: 'Reversal scanner' },
|
||||
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC Macro Bottom scanner', label: 'BTC Macro Bottom' },
|
||||
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC Funding Rate Reversal scanner', label: 'Funding Reversal' },
|
||||
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL Talks-vs-Trades Divergence', label: 'KOL Divergence' },
|
||||
sma_reclaim: { glyph: '〽', cls: 'breakout', title: 'SMA reclaim scanner', label: 'SMA Reclaim' },
|
||||
rsi_reversal: { glyph: '⇋', cls: 'reversal', title: 'RSI reversal scanner', label: 'RSI Reversal' },
|
||||
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert', label: 'Whale alert' },
|
||||
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry', label: 'Manual' },
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: string }) {
|
||||
@@ -102,18 +114,14 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
// Did this post actually get an AI score, or was it filtered as off-topic
|
||||
// noise before any AI call? Trump posts mostly aren't crypto-related, so the
|
||||
// entry filter / AI marks them relevant=false with confidence 0 and no
|
||||
// reasoning. Showing "AI confidence 0%" + an empty bar for those is
|
||||
// misleading — it reads as "AI looked and had zero confidence" when really
|
||||
// "this was skipped as not crypto-relevant". Treat a post as AI-scored only
|
||||
// when it has a real confidence or reasoning.
|
||||
const aiScored = post.ai_confidence > 0 || !!post.ai_reasoning
|
||||
// Off-topic Trump posts (most of them) are filtered before any AI call, so
|
||||
// they carry confidence 0 + no reasoning. Treat those as un-scored noise and
|
||||
// de-emphasise them. Shared helper so list-level "collapse off-topic" agrees.
|
||||
const aiScored = isAiScored(post)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`post-row ${selected ? 'selected' : ''}`}
|
||||
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'} ${post.signal === 'buy' ? 'signal-buy' : post.signal === 'short' ? 'signal-short' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* ── main row ── */}
|
||||
@@ -124,53 +132,79 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* Author label depends on source — non-Trump signals come from
|
||||
technical scanners or external modules, not @realDonaldTrump. */}
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
|
||||
{post.source === 'truth' ? '@realDonaldTrump' : post.source}
|
||||
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<TimeAgo iso={post.published_at} suffix=" ago" />
|
||||
<span>·</span>
|
||||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
|
||||
{post.sentiment}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text" style={expanded ? { display: 'block', overflow: 'visible', WebkitLineClamp: 'unset' } : {}}>
|
||||
{expanded ? post.text : (post.text.slice(0, 180) + (post.text.length > 180 ? '…' : ''))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="post-aside">
|
||||
<SignalPill signal={post.signal} />
|
||||
{/* Target asset chip for actionable signals */}
|
||||
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, fontFamily: 'var(--mono)',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
|
||||
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{post.target_asset}
|
||||
{post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''}
|
||||
</span>
|
||||
)}
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
<span className="tf">1h peak</span>
|
||||
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
|
||||
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
|
||||
</span>
|
||||
</>
|
||||
{aiScored ? (
|
||||
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`} style={{ padding: '2px 8px', fontSize: 11 }}>
|
||||
{post.sentiment}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tf">no data</span>
|
||||
<span style={{
|
||||
padding: '2px 8px', fontSize: 11, fontWeight: 600,
|
||||
borderRadius: 4, color: 'var(--ink-4)',
|
||||
background: 'var(--bg-sunk)', border: '1px solid var(--line)',
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
off-topic · not crypto
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
<span>AI</span>
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
|
||||
{aiScored ? `${post.ai_confidence}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="text"
|
||||
style={{
|
||||
...(expanded ? { display: 'block', overflow: 'visible', WebkitLineClamp: 'unset' } : {}),
|
||||
// Noise posts are de-emphasised: dimmer text + shorter preview so
|
||||
// the reader can skip them at a glance without opening.
|
||||
...(aiScored ? {} : { color: 'var(--ink-4)' }),
|
||||
}}
|
||||
>
|
||||
{expanded
|
||||
? post.text
|
||||
: (post.text.slice(0, aiScored ? 180 : 90) + (post.text.length > (aiScored ? 180 : 90) ? '…' : ''))}
|
||||
</p>
|
||||
</div>
|
||||
{/* Aside (signal pill / target / impact / AI%) only for scored posts.
|
||||
Noise posts render as a compact two-column row (no aside) — the
|
||||
'off-topic · not crypto' tag in the meta line is enough. */}
|
||||
{aiScored && (
|
||||
<div className="post-aside">
|
||||
<SignalPill signal={post.signal} />
|
||||
{/* Target asset chip for actionable signals */}
|
||||
{post.target_asset && (post.signal === 'buy' || post.signal === 'short') && (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, fontFamily: 'var(--mono)',
|
||||
padding: '2px 6px', borderRadius: 4,
|
||||
background: post.signal === 'buy' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
|
||||
color: post.signal === 'buy' ? '#22c55e' : '#ef4444',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
{post.target_asset}
|
||||
{post.expected_move_pct ? ` +${post.expected_move_pct}%` : ''}
|
||||
</span>
|
||||
)}
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
<span className="tf">1h move</span>
|
||||
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
|
||||
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="tf">no data</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="row gap-s" style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
<span>AI</span>
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
|
||||
{post.ai_confidence}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── expanded detail ── */}
|
||||
@@ -214,7 +248,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
</span>
|
||||
{post.expected_move_pct != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{`AI expects ~${post.expected_move_pct}% in 1h`}
|
||||
{`model projection ~${post.expected_move_pct}% · 1h`}
|
||||
</span>
|
||||
)}
|
||||
{post.category && (
|
||||
@@ -228,7 +262,9 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<div>
|
||||
<div className="ai-reasoning-label">AI reasoning</div>
|
||||
<div className="ai-reasoning-label">
|
||||
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
|
||||
</div>
|
||||
<div className="ai-reasoning-card">
|
||||
{post.ai_reasoning}
|
||||
</div>
|
||||
@@ -238,7 +274,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* Price impact — peak move in signal direction per window */}
|
||||
{impact && (
|
||||
<div>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>{`Peak move · ${impact.asset}`}</div>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>{`Price moved · ${impact.asset} · after signal`}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{(['m5', 'm15', 'm1h'] as const).map(key => {
|
||||
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
const API_BASE = '/api/proxy/api'
|
||||
@@ -30,82 +29,22 @@ function timeAgo(iso: string) {
|
||||
return `${Math.round(diff / 3600)}h ago`
|
||||
}
|
||||
|
||||
// ── iOS-style toggle switch ───────────────────────────────────────────────────
|
||||
function ToggleSwitch({ on, loading, onToggle }: {
|
||||
on: boolean
|
||||
loading: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={loading}
|
||||
title={on ? 'Click to disable' : 'Click to enable'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
opacity: loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Switch track */}
|
||||
<div style={{
|
||||
width: 44,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
background: on ? '#22c55e' : 'var(--ink-4)',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
opacity: on ? 1 : 0.5,
|
||||
}}>
|
||||
{/* Thumb */}
|
||||
<div style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: on ? 20 : 2,
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: on ? '#22c55e' : 'var(--ink-3)',
|
||||
letterSpacing: '0.05em',
|
||||
minWidth: 24,
|
||||
}}>
|
||||
{on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
// Display-only: toggle is operator-only (requires X-Ingest-Key) so the
|
||||
// on/off switch is intentionally absent from the user-facing UI.
|
||||
export default function SignalMonitor() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [backendOk, setBackendOk] = useState<boolean | null>(null)
|
||||
const [signals, setSignals] = useState<SignalAlert[]>([])
|
||||
const [btcTrend, setBtcTrend] = useState<string | null>(null)
|
||||
const [lastScan, setLastScan] = useState<Date | null>(null)
|
||||
const [signals, setSignals] = useState<SignalAlert[]>([])
|
||||
const [btcTrend, setBtcTrend] = useState<string | null>(null)
|
||||
const [lastScan, setLastScan] = useState<Date | null>(null)
|
||||
|
||||
// ── Load initial state ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/signal/status`)
|
||||
.then(r => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then(d => { setEnabledState(d.enabled); setBackendOk(true) })
|
||||
.catch(() => setBackendOk(false))
|
||||
.then(d => { setEnabledState(d.enabled) })
|
||||
.catch(() => {})
|
||||
|
||||
fetch(`${API_BASE}/signal/history?limit=20`)
|
||||
.then(r => r.json())
|
||||
@@ -128,22 +67,6 @@ export default function SignalMonitor() {
|
||||
setSignals(prev => [alert, ...prev].slice(0, 50))
|
||||
})
|
||||
|
||||
// ── Toggle ───────────────────────────────────────────────────────────────
|
||||
const toggle = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const next = !enabled
|
||||
const r = await fetch(`${API_BASE}/signal/toggle?enabled=${next}`, { method: 'POST' })
|
||||
if (!r.ok) throw new Error()
|
||||
const d = await r.json()
|
||||
setEnabledState(d.enabled)
|
||||
setBackendOk(true)
|
||||
} catch {
|
||||
setBackendOk(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [enabled])
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
const btcUp = btcTrend?.includes('↑')
|
||||
|
||||
@@ -160,20 +83,16 @@ export default function SignalMonitor() {
|
||||
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={enabled} loading={loading} onToggle={toggle} />
|
||||
</div>
|
||||
|
||||
{/* Backend offline warning */}
|
||||
{backendOk === false && (
|
||||
<div style={{
|
||||
fontSize: 11, color: '#f59e0b',
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: 'rgba(245,158,11,0.1)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
⚠️ {isZh ? '后端离线,开关暂时不可用' : "Backend offline — toggle won't work"}
|
||||
{/* Enabled status dot — display-only; toggle is operator-only (X-Ingest-Key) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: enabled ? '#22c55e' : 'var(--ink-3)' }}>
|
||||
<div style={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: enabled ? '#22c55e' : 'var(--ink-4)',
|
||||
boxShadow: enabled ? '0 0 0 2px rgba(34,197,94,0.25)' : 'none',
|
||||
}} />
|
||||
{enabled ? (isZh ? '监控中' : 'Active') : (isZh ? '已暂停' : 'Paused')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status row: BTC trend + last scan */}
|
||||
<div style={{
|
||||
@@ -208,7 +127,7 @@ export default function SignalMonitor() {
|
||||
}}>
|
||||
{enabled
|
||||
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
|
||||
: (isZh ? '· 打开开关后开始监控' : '· Enable the toggle to start watching')}
|
||||
: (isZh ? '· 暂无历史信号' : '· No signals yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
import { needsMobileWallet } from '@/lib/mobileWallet'
|
||||
import MobileWalletSheet from '@/components/wallet/MobileWalletSheet'
|
||||
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
|
||||
// import LanguageSwitch from './LanguageSwitch'
|
||||
|
||||
@@ -31,7 +33,12 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="icon-btn theme-toggle" onClick={toggle}>
|
||||
<button
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||
@@ -56,6 +63,7 @@ export default function Navbar() {
|
||||
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [connectError, setConnectError] = useState('')
|
||||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false)
|
||||
|
||||
async function copyAddress(addr: string) {
|
||||
let ok = false
|
||||
@@ -89,10 +97,20 @@ export default function Navbar() {
|
||||
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
|
||||
// On mobile without an injected provider, show the wallet deep-link sheet
|
||||
// instead of throwing "No wallet found". This lets users open the dApp
|
||||
// inside MetaMask / Trust / Coinbase without a confusing error.
|
||||
if (needsMobileWallet()) {
|
||||
setMobileSheetOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
// Desktop with no extension — give a helpful hint instead of raw error.
|
||||
setConnectError('No wallet extension found. Install MetaMask or another browser wallet.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
@@ -124,6 +142,8 @@ export default function Navbar() {
|
||||
const shortAddr = address ? `${address.slice(0, 6)}…${address.slice(-4)}` : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileWalletSheet open={mobileSheetOpen} onClose={() => setMobileSheetOpen(false)} />
|
||||
<nav className="nav">
|
||||
<Link href={`/${locale}`} className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<BrandMark />
|
||||
@@ -204,5 +224,6 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Ticker — horizontal scrolling price tape under the navbar, the way a crypto
|
||||
* terminal shows a running market strip. Driven by the shared WebSocket price
|
||||
* feed (no new socket). Each asset cell flashes green/red on a price change and
|
||||
* shows the live last price.
|
||||
*
|
||||
* Behaviour notes:
|
||||
* - The strip is duplicated once and translated -50% in a CSS marquee so the
|
||||
* loop is seamless (`ticker-marquee` keyframe in globals.css).
|
||||
* - prefers-reduced-motion disables the scroll (CSS), so the tape becomes a
|
||||
* static, readable row instead of moving.
|
||||
* - The animation is paused on hover so a user can read a specific price.
|
||||
* - Assets with no tick yet render "—" rather than a stale zero.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
// Assets the backend streams (see binance ASSET_MAP + hl_price_feed). Order is
|
||||
// the display order in the tape.
|
||||
const ASSETS = ['BTC', 'ETH', 'SOL', 'BNB', 'DOGE', 'LINK', 'AAVE', 'TRUMP', 'HYPE'] as const
|
||||
|
||||
interface Cell {
|
||||
price: number | null
|
||||
dir: 'up' | 'down' | null
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (p >= 1000) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 0 })
|
||||
if (p >= 1) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||
return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
export default function Ticker() {
|
||||
const [cells, setCells] = useState<Record<string, Cell>>({})
|
||||
// Per-asset flash-clear timers so a fast stream doesn't leak setTimeouts.
|
||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
useWsSubscribe('price', (msg) => {
|
||||
const m = msg as { asset?: string; price?: number }
|
||||
if (!m.asset || typeof m.price !== 'number') return
|
||||
const asset = m.asset.toUpperCase()
|
||||
if (!ASSETS.includes(asset as (typeof ASSETS)[number])) return
|
||||
|
||||
setCells((prev) => {
|
||||
const old = prev[asset]
|
||||
const dir: Cell['dir'] =
|
||||
old?.price != null && m.price !== old.price
|
||||
? (m.price! > old.price ? 'up' : 'down')
|
||||
: old?.dir ?? null
|
||||
return { ...prev, [asset]: { price: m.price!, dir } }
|
||||
})
|
||||
|
||||
// Clear the flash after 600ms.
|
||||
clearTimeout(timers.current[asset])
|
||||
timers.current[asset] = setTimeout(() => {
|
||||
setCells((prev) => (prev[asset] ? { ...prev, [asset]: { ...prev[asset], dir: null } } : prev))
|
||||
}, 600)
|
||||
})
|
||||
|
||||
const strip = ASSETS.map((a) => {
|
||||
const c = cells[a]
|
||||
return (
|
||||
<span key={a} className={`ticker-cell ${c?.dir ? `tick-flash-${c.dir}` : ''}`}>
|
||||
<span className="ticker-sym">{a}</span>
|
||||
<span className="ticker-px">{c?.price != null ? fmtPrice(c.price) : '—'}</span>
|
||||
<span className={`ticker-arrow ${c?.dir ?? ''}`} aria-hidden>
|
||||
{c?.dir === 'up' ? '▲' : c?.dir === 'down' ? '▼' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="ticker" role="region" aria-label="Live market prices">
|
||||
<div className="ticker-track">
|
||||
{strip}
|
||||
{/* duplicate for seamless loop */}
|
||||
<span aria-hidden style={{ display: 'contents' }}>{strip}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type OpenPosition,
|
||||
type TodayStats,
|
||||
} from '@/lib/api'
|
||||
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest, type SignedEnvelope } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
@@ -85,7 +86,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</span>
|
||||
{/* Entry → Current */}
|
||||
<div className="mono" style={{ fontSize: 12 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry → mark</div>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry / current price</div>
|
||||
<div>
|
||||
{fmtMoney(p.entry_price)}
|
||||
<span style={{ color: 'var(--ink-4)' }}> → </span>
|
||||
@@ -102,10 +103,10 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
|
||||
<div style={{ fontSize: 9, marginTop: 2 }}>
|
||||
{(p.addon_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
|
||||
<span style={{ color: 'var(--up)' }}>{`↑ scaled in ×${p.addon_steps} `}</span>
|
||||
)}
|
||||
{(p.derisk_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
|
||||
<span style={{ color: 'var(--down)' }}>{`↓ trimmed ×${p.derisk_steps}`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -114,8 +115,8 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
onClick={() => onToggleGrow(p)}
|
||||
disabled={growBusy}
|
||||
title={p.grow_mode
|
||||
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
|
||||
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
|
||||
? (isZh ? '加仓已开启:趋势确认后自动追加仓位。点击关闭。' : 'Scale-in ON — bot adds to this position when trend is confirmed. Click to turn off.')
|
||||
: (isZh ? '加仓已关闭:仅持有并做保护性减仓。' : 'Scale-in OFF — hold + protective trim only. Click to allow adding.')}
|
||||
style={{
|
||||
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
|
||||
@@ -124,7 +125,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
|
||||
{p.grow_mode ? '↑ Scale-in ON' : 'Scale-in OFF'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Hold time */}
|
||||
@@ -141,7 +142,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</div>
|
||||
{p.realized_usd != null && p.realized_usd !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
locked in {fmtMoney(p.realized_usd, { sign: true })}
|
||||
banked {fmtMoney(p.realized_usd, { sign: true })} from partial close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,6 +173,7 @@ export default function OpenPositions() {
|
||||
const [today, setToday] = useState<TodayStats | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||||
const [unlocking, setUnlocking] = useState(false)
|
||||
// Close-confirmation modal state
|
||||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||||
@@ -212,10 +214,11 @@ export default function OpenPositions() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
return
|
||||
}
|
||||
// B39: wipe stale data from the previous wallet immediately — don't wait
|
||||
// for the async fetch to complete before the UI shows a clean slate.
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
|
||||
if (!isConnected || !address) return
|
||||
let cancelled = false
|
||||
|
||||
// Only reuse a cached envelope here. Open positions should never trigger
|
||||
@@ -253,6 +256,31 @@ export default function OpenPositions() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, isConnected])
|
||||
|
||||
// In-page unlock: mint a view_user envelope (one signature) right here and
|
||||
// load positions immediately — no detour to the Settings page. view_user is
|
||||
// a superset accepted by /positions/open and /positions/today.
|
||||
async function handleUnlock() {
|
||||
if (!address || unlocking) return
|
||||
setUnlocking(true); setErr(null)
|
||||
try {
|
||||
const env = await getOrCreateViewEnvelope({
|
||||
action: 'view_user', wallet: address, signMessageAsync,
|
||||
})
|
||||
const [p, t] = await Promise.all([
|
||||
getOpenPositions(address, env),
|
||||
getTodayStats(address, env),
|
||||
])
|
||||
setPositions(p.positions); setToday(t); setNeedsUnlock(false); setErr(null)
|
||||
} catch (e: unknown) {
|
||||
// User-cancelled signature is benign — keep the unlock CTA visible.
|
||||
if (!isUserRejection(e)) {
|
||||
setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '解锁失败' : 'unlock failed'))
|
||||
}
|
||||
} finally {
|
||||
setUnlocking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||||
async function confirmCloseTrade() {
|
||||
if (!address || !closing) return
|
||||
@@ -293,15 +321,39 @@ export default function OpenPositions() {
|
||||
Open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Sign in once to view open positions
|
||||
Sign in to see open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
Go to the <strong>Settings page</strong> and click <em>Sign in to view your settings</em>. After signing, your open positions will appear here automatically.
|
||||
Sign once on this device to unlock your positions (valid for a few
|
||||
minutes, no transaction, no gas).
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px' }}
|
||||
disabled={unlocking} onClick={handleUnlock}>
|
||||
{unlocking ? 'Waiting for signature…' : 'Unlock positions'}
|
||||
</button>
|
||||
<Link href={`/${locale}/settings`} style={{ fontSize: 12, color: 'var(--ink-3)', textDecoration: 'none' }}>
|
||||
or go to Settings →
|
||||
</Link>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>⚠️ {err}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (positions === null && today === null) return null
|
||||
// When the first load fails, positions and today are still null but err is set.
|
||||
// Without this guard the component returns null (blank), swallowing the error.
|
||||
if (positions === null && today === null) {
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '12px 16px', marginBottom: 16, fontSize: 12, color: 'var(--down)' }}>
|
||||
⚠️ Could not load positions — {err}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const totalUnrealized = (positions ?? []).reduce(
|
||||
(s, p) => s + (p.unrealized_usd ?? 0), 0,
|
||||
@@ -363,7 +415,7 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} locked in on open trades`}
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -447,8 +499,8 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{closing.is_paper
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
|
||||
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会动用真实资金。' : 'Paper trade — simulated close, no real funds involved.')
|
||||
: (isZh ? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。' : 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.')}
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
|
||||
@@ -458,17 +510,17 @@ export default function OpenPositions() {
|
||||
<span className="mono">{fmtMoney(closing.entry_price)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Current price'}</span>
|
||||
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
|
||||
</div>
|
||||
{closing.realized_usd != null && closing.realized_usd !== 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
|
||||
<span>{isZh ? '已锁定(降风险)' : 'Locked in (de-risked)'}</span>
|
||||
<span>{isZh ? '已落袋(之前减仓)' : 'Already banked'}</span>
|
||||
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||||
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
|
||||
<span>{isZh ? '平仓后将结算' : 'You\'ll get if you close now'}</span>
|
||||
<span style={{
|
||||
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
|
||||
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
|
||||
@@ -25,8 +25,8 @@ import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
*/
|
||||
|
||||
const SYS = {
|
||||
trump: { idx: '①', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
|
||||
btc: { idx: '②', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
|
||||
trump: { idx: '', name: 'Trump Signal', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
|
||||
btc: { idx: '', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
|
||||
} as const
|
||||
|
||||
const ROW: React.CSSProperties = {
|
||||
@@ -49,9 +49,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Generation counter: each address change increments it so any in-flight
|
||||
// refresh from the previous address can detect it's stale.
|
||||
// `snap !== address` inside a useCallback is a stale-closure trap — both
|
||||
// refer to the same closed-over value so the check is always false.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) { setPub(null); return }
|
||||
const gen = genRef.current
|
||||
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
|
||||
if (gen !== genRef.current) return // wallet changed while in-flight
|
||||
setPub(p)
|
||||
}, [address])
|
||||
|
||||
@@ -71,6 +80,14 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (busy) return
|
||||
if (!address) { setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).'); return }
|
||||
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
|
||||
// B51: live users without an HL API key cannot actually execute trades —
|
||||
// block the toggle so the ON state never silently becomes a no-op.
|
||||
if (on && !paper && !pub?.hl_api_key_set) {
|
||||
setErr(isZh
|
||||
? '未绑定 Hyperliquid API Key。请先在设置页保存 API Key,Auto-Trade 才能真实下单。'
|
||||
: 'No Hyperliquid API key saved. Add your HL API key on the Settings page before enabling Auto-Trade.')
|
||||
return
|
||||
}
|
||||
if (autoOn === on) return
|
||||
|
||||
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
|
||||
@@ -132,7 +149,6 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (!mounted) return null
|
||||
|
||||
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
|
||||
// trade. bot_engine.process_post early-returns for sys2 sources, so the
|
||||
@@ -141,38 +157,34 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
// auto-open and (b) be a confusing second copy of the SAME global switch
|
||||
// shown on the Trump page. Show the adopt-only flow instead.
|
||||
if (system === 'btc') {
|
||||
const linkStyle: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 999, border: '1px solid var(--line)', background: 'var(--surface)',
|
||||
fontSize: 12, color: 'var(--ink)', textDecoration: 'none', fontWeight: 700,
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}
|
||||
// Compact inline strip — signal fires → you open → bot manages.
|
||||
// No big card; the data panel is the main content. Settings link stays
|
||||
// for users who want to configure, but the explanation is one line.
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 16px', background: s.soft,
|
||||
borderLeft: `4px solid ${s.accent}` }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
|
||||
{s.idx} {s.name} — manage-only
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6 }}>
|
||||
<p style={{ margin: '0 0 10px' }}>
|
||||
<strong>Macro Vibes does not auto-open trades.</strong> When a bottom-reversal
|
||||
signal fires you get a Telegram alert. You open the position yourself on
|
||||
Hyperliquid, then hand it to the bot with <code>/adopt</code> — the bot then
|
||||
manages the exit (staged stop ladder, de-risk, pyramid, peak-trail).
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', color: 'var(--ink-4)', fontSize: 12 }}>
|
||||
The Auto-Trade switch on the Trump page controls Trump (System 1)
|
||||
auto-opens only — it has no effect on Macro Vibes.
|
||||
</p>
|
||||
<Link href={settingsHref} style={linkStyle}>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
color: 'var(--ink-4)' }}>Settings</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12, flexWrap: 'wrap',
|
||||
padding: '9px 14px', marginBottom: 14,
|
||||
borderRadius: 8,
|
||||
background: s.soft,
|
||||
borderLeft: `3px solid ${s.accent}`,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: s.accent }}>You open · bot manages exit</strong>
|
||||
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
|
||||
Signal → Telegram → open on Hyperliquid → <code style={{ fontSize: 11 }}>/adopt</code>
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={settingsHref}
|
||||
style={{
|
||||
fontSize: 11, color: s.accent, textDecoration: 'none',
|
||||
fontWeight: 700, flexShrink: 0,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Settings ↗
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,8 +210,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: 'var(--ink-4)', flexShrink: 0,
|
||||
}} />
|
||||
{/* No "{s.name}" prefix — the page title right above already names
|
||||
the system; repeating it here read "Trump Signal" twice per screen. */}
|
||||
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
|
||||
{s.name} Auto-Trade
|
||||
Auto-Trade
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>
|
||||
@@ -223,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span>Settings</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -248,11 +259,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
netMsg = isZh
|
||||
? 'Auto-Trade 当前为关闭: Trump 信号仍会显示,但不会自动开仓。'
|
||||
: 'Auto-Trade is OFF — Trump signals are shown in the feed but NOT traded.'
|
||||
} else if (pub?.trump_enabled === false) {
|
||||
// Auto-Trade is ON but the Trump (System-1) gate is OFF, so the backend
|
||||
// skips every Trump signal (bot_engine: trump_enabled OFF → not traded).
|
||||
// Without this branch the UI falsely promised "next signal auto-opens".
|
||||
netMsg = isZh
|
||||
? 'Auto-Trade 已开启,但 Trump 系统①未启用: 在设置页打开 Trump 开关后才会自动开仓。'
|
||||
: 'Auto-Trade is ON, but the Trump system is disabled — enable Trump in Settings before signals will trade.'
|
||||
} else {
|
||||
netOk = true
|
||||
netMsg = isZh
|
||||
? `Auto-Trade 已开启(Trump 系统①): 下一条合格 Trump 信号会自动开出${paper ? '模拟' : '真实'}仓位。`
|
||||
: `Auto-Trade ON (Trump / System 1) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
|
||||
: `Auto-Trade ON — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
|
||||
}
|
||||
|
||||
const Pill = ({ active, onClick, children, tone }: {
|
||||
@@ -277,37 +295,20 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
{/* Header */}
|
||||
<div style={{ padding: '14px 16px', background: s.soft,
|
||||
borderLeft: `4px solid ${s.accent}` }}>
|
||||
{/* Header is just "Auto-Trade" — the page title already names the
|
||||
system, and the switch row below repeats "Auto-Trade" otherwise. */}
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
|
||||
{s.idx} {s.name} {isZh ? '控制面板' : '— control'}
|
||||
{isZh ? '控制面板' : 'Auto-Trade'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master Auto-Trade switch */}
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={ROW}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· Trump 系统①' : '· TRUMP (System 1)'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
|
||||
{isZh ? (
|
||||
<>
|
||||
<strong>控制 Trump 信号是否自动开仓。</strong> 关闭时:信号仍会扫描并显示,但不会自动交易。
|
||||
开启时:满足条件的 Trump 信号会自动开仓。无论开关状态如何,
|
||||
已开仓位的止损和分级降风险都会继续保护持仓。
|
||||
(Macro Vibes 不自动开仓——见 Macro 页的 manage-only 说明。)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Controls Trump (System 1) auto-opens.</strong> OFF: signals
|
||||
scanned & shown in the feed, nothing traded. ON: a qualifying
|
||||
Trump signal auto-opens a trade. Stop-loss / staged de-risk always
|
||||
protect open positions either way. (Macro Vibes is manage-only and
|
||||
never auto-opens — see its page.)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* The card header above already says "Auto-Trade" — this row only
|
||||
carries the ON/OFF legend, not a second title. */}
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
|
||||
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
|
||||
@@ -372,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
|
||||
Settings
|
||||
</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span>Settings</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
@@ -36,19 +36,36 @@ export default function TelegramCard() {
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Generation counter guards against stale-closure trap: `snap !== address`
|
||||
// inside useCallback compares two closed-over copies of the same value and
|
||||
// is always false. genRef is a mutable ref readable from any closure.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) {
|
||||
setLoading(false)
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
if (!address) { setLoading(false); setStatus(null); return }
|
||||
const gen = genRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const s = await getTelegramStatus(address.toLowerCase())
|
||||
const { getCachedViewEnvelope } = await import('@/lib/signedRequest')
|
||||
const cached = getCachedViewEnvelope('view_user', address)
|
||||
const s = await getTelegramStatus(address.toLowerCase(), cached ?? undefined)
|
||||
if (gen !== genRef.current) return // wallet changed while in-flight
|
||||
setStatus(s); setErr('')
|
||||
} catch (e) {
|
||||
if (gen !== genRef.current) return
|
||||
setErr(e instanceof Error ? e.message : 'load failed')
|
||||
} finally { setLoading(false) }
|
||||
} finally {
|
||||
if (gen === genRef.current) setLoading(false)
|
||||
}
|
||||
}, [address])
|
||||
|
||||
// B32: clear stale status immediately when wallet changes so the previous
|
||||
// wallet's binding info never flickers in before the new fetch resolves.
|
||||
useEffect(() => {
|
||||
setStatus(null)
|
||||
setCode(null)
|
||||
setErr('')
|
||||
}, [address])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
@@ -112,7 +129,7 @@ export default function TelegramCard() {
|
||||
Telegram alerts
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||||
Connect your wallet first. After that, you can link Telegram for alert delivery and Pro wallet-bound notifications.
|
||||
You can get free Telegram alerts without a wallet — just open the bot. Connect a wallet here to also get alerts tailored to your own positions.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -180,8 +197,8 @@ export default function TelegramCard() {
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
|
||||
{status.bound
|
||||
? (isZh ? '打开机器人调整偏好(/trump /btc /funding /kol /conf /quiet)' : 'Open the bot to adjust preferences (/trump /btc /funding /kol /conf /quiet)')
|
||||
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and send /start — no account needed')}
|
||||
? (isZh ? '在机器人里可开关各类提醒、设置免打扰时段' : 'Open the bot to choose which alerts you get and set quiet hours')
|
||||
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and tap Start — no account needed')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,11 +214,11 @@ export default function TelegramCard() {
|
||||
</span>
|
||||
{status.wallet_address ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
|
||||
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
|
||||
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
|
||||
@@ -209,7 +226,7 @@ export default function TelegramCard() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,9 +6,11 @@ import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
getUserPublic,
|
||||
getUser,
|
||||
getTelegramStatus,
|
||||
setUserSettings,
|
||||
setHlApiKey,
|
||||
setManualWindow,
|
||||
setAutoTrade,
|
||||
subscribe,
|
||||
type UserSettings,
|
||||
} from '@/lib/api'
|
||||
@@ -30,18 +32,27 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
trump_enabled: false, macro_enabled: false,
|
||||
}
|
||||
|
||||
// The backend treats active_from/active_until as a DAILY RECURRING time window
|
||||
// (it extracts only .time() and compares with the current UTC time). Storing a
|
||||
// full datetime-local value was misleading — the date part was silently ignored
|
||||
// so users thought they were setting a date range when they were setting a
|
||||
// time-of-day schedule. We now use type="time" and store as a fixed-epoch ISO
|
||||
// so the backend's .time() extraction gives the right HH:MM:SS.
|
||||
function isoToLocalInput(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
// Return just HH:MM for the time input
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
|
||||
}
|
||||
function localInputToIso(v: string): string | null {
|
||||
if (!v) return null
|
||||
const d = new Date(v)
|
||||
if (isNaN(d.getTime())) return null
|
||||
return d.toISOString()
|
||||
// v is HH:MM from <input type="time">. Store as 2000-01-01T{HH:MM}:00Z so
|
||||
// the backend's .time() extraction returns the correct UTC time.
|
||||
const [hh, mm] = v.split(':')
|
||||
if (!hh || !mm) return null
|
||||
return `2000-01-01T${hh.padStart(2,'0')}:${mm.padStart(2,'0')}:00Z`
|
||||
}
|
||||
|
||||
export default function BotConfigPanel() {
|
||||
@@ -51,7 +62,7 @@ export default function BotConfigPanel() {
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
const {
|
||||
isSubscribed, hlApiKeySet, hlApiKeyMasked,
|
||||
setSubscribed, setBotReadiness, setHlApiKeySet,
|
||||
setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore,
|
||||
} = useDashboardStore()
|
||||
|
||||
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
||||
@@ -75,24 +86,66 @@ export default function BotConfigPanel() {
|
||||
const [mwErr, setMwErr] = useState('')
|
||||
const [mwTick, setMwTick] = useState(0)
|
||||
const [paperMode, setPaperMode] = useState(false)
|
||||
const [autoTrade, setAutoTrade_] = useState(false)
|
||||
const [atState, setAtState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle')
|
||||
const [loadErr, setLoadErr] = useState('')
|
||||
const [connectErr, setConnectErr] = useState('')
|
||||
// Telegram binding state. Macro Vibes (System-2) is manage-only via the
|
||||
// Telegram /adopt command, so a Macro-only user who hasn't bound Telegram
|
||||
// cannot actually hand any position to the bot — readiness must reflect that.
|
||||
// The unauthenticated status call returns only { bound } (no chat_id), which
|
||||
// is all we need here.
|
||||
const [tgBound, setTgBound] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) { setTgBound(null); return }
|
||||
let cancelled = false
|
||||
getTelegramStatus(address.toLowerCase())
|
||||
.then(s => { if (!cancelled) setTgBound(!!s.bound) })
|
||||
.catch(() => { if (!cancelled) setTgBound(null) })
|
||||
return () => { cancelled = true }
|
||||
}, [address])
|
||||
|
||||
// B33: when the connected wallet changes, immediately wipe all private
|
||||
// settings so the previous wallet's config never leaks into the new one.
|
||||
useEffect(() => {
|
||||
setSettings(DEFAULT_SETTINGS)
|
||||
setPaperMode(false)
|
||||
setAutoTrade_(false)
|
||||
setTpConfigured(false)
|
||||
setSlConfigured(false)
|
||||
setUseBudget(false)
|
||||
setUseSchedule(false)
|
||||
setFromLocal('')
|
||||
setUntilLocal('')
|
||||
setManualUntil(null)
|
||||
setApiKey('')
|
||||
setDirty(false)
|
||||
setSaveState('idle'); setSaveErr('')
|
||||
setKeyState('idle'); setKeyErr('')
|
||||
setSubState('idle'); setSubErr('')
|
||||
setLoadState('idle'); setLoadErr('')
|
||||
setConnectErr('')
|
||||
}, [address])
|
||||
|
||||
function applyUserPayload(u: {
|
||||
active: boolean
|
||||
hl_api_key_set: boolean
|
||||
hl_api_key_masked: string | null
|
||||
paper_mode?: boolean
|
||||
auto_trade?: boolean
|
||||
settings: UserSettings
|
||||
manual_window_until?: string | null
|
||||
}) {
|
||||
setSubscribed(u.active)
|
||||
setPaperMode(!!u.paper_mode)
|
||||
setPaperModeStore(!!u.paper_mode) // sync to global store for B40/B41
|
||||
setAutoTrade_(!!u.auto_trade)
|
||||
setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined)
|
||||
setManualUntil(u.manual_window_until ?? null)
|
||||
if (u.settings) {
|
||||
@@ -126,6 +179,7 @@ export default function BotConfigPanel() {
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setAutoTrade_(!!pub.auto_trade)
|
||||
if (!pub.active) return
|
||||
const cached = getCachedViewEnvelope('view_user', address)
|
||||
if (!cached) return
|
||||
@@ -141,11 +195,8 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
setLoadErr(''); setLoadState('loading')
|
||||
try {
|
||||
const ok = await confirmSign({
|
||||
label: 'View account settings',
|
||||
description: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification — no on-chain action is performed.',
|
||||
})
|
||||
if (!ok) { setLoadState('idle'); return }
|
||||
// Read-only identity check — go straight to MetaMask, no pre-confirm sheet.
|
||||
// getOrCreateViewEnvelope caches the result for 4 min so repeat visits are free.
|
||||
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
|
||||
const u = await getUser(address, env)
|
||||
applyUserPayload(u)
|
||||
@@ -161,9 +212,34 @@ export default function BotConfigPanel() {
|
||||
setSubscribed(pub.active)
|
||||
setHlApiKeySet(pub.hl_api_key_set)
|
||||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||||
setAutoTrade_(!!pub.auto_trade)
|
||||
return pub
|
||||
}
|
||||
|
||||
async function flipAutoTrade(on: boolean) {
|
||||
if (!address || atState !== 'idle') return
|
||||
const ok = await confirmSign({
|
||||
label: on ? 'Enable Auto-Trade' : 'Disable Auto-Trade',
|
||||
description: on
|
||||
? 'Bot will open real positions on Hyperliquid when qualifying Trump signals fire. Stop-loss and de-risking remain active.'
|
||||
: 'Bot stops opening new trades. Risk controls on existing positions keep running.',
|
||||
danger: on,
|
||||
})
|
||||
if (!ok) return
|
||||
setAtState('signing')
|
||||
try {
|
||||
const env = await signRequest({ action: 'set_auto_trade', wallet: address, body: { enabled: on }, signMessageAsync })
|
||||
setAtState('saving')
|
||||
const r = await setAutoTrade(env, on)
|
||||
setAutoTrade_(r.auto_trade)
|
||||
setAtState('idle')
|
||||
} catch (e: unknown) {
|
||||
console.error('set_auto_trade failed', e)
|
||||
setAtState('err')
|
||||
setTimeout(() => setAtState('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSettings(patch: Partial<UserSettings>) {
|
||||
setSettings(s => ({ ...s, ...patch }))
|
||||
setDirty(true)
|
||||
@@ -202,7 +278,7 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
const ok = await confirmSign({
|
||||
label: 'Save trading settings',
|
||||
description: 'Settings are saved to the server and apply to all new trades from this point forward. Open positions are not affected.',
|
||||
description: 'Applied to all new trades going forward. Open positions are not affected.',
|
||||
})
|
||||
if (!ok) return
|
||||
setSaveErr(''); setSaveState('signing')
|
||||
@@ -213,7 +289,11 @@ export default function BotConfigPanel() {
|
||||
schedFrom = localInputToIso(fromLocal)
|
||||
schedUntil = localInputToIso(untilLocal)
|
||||
if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return }
|
||||
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); setSaveState('err'); return }
|
||||
// Cross-midnight windows (e.g. 22:00–02:00) are VALID — the backend's
|
||||
// _is_in_active_window treats af_t > au_t as a wrap-around window
|
||||
// (now >= from OR now <= until). Only reject when start === end, which
|
||||
// would describe a zero-length (or full-day-ambiguous) window.
|
||||
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end can’t be the same time'); setSaveState('err'); return }
|
||||
}
|
||||
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
|
||||
if (trumpOn) {
|
||||
@@ -244,7 +324,7 @@ export default function BotConfigPanel() {
|
||||
}
|
||||
const ok = await confirmSign({
|
||||
label: 'Link Hyperliquid API key',
|
||||
description: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
|
||||
description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -304,7 +384,7 @@ export default function BotConfigPanel() {
|
||||
if (!address) return
|
||||
const ok = await confirmSign({
|
||||
label: 'Switch to live trading',
|
||||
description: 'Your subscription will change from paper mode to live. For your safety, Auto-Trade is turned OFF on this switch — you must re-enable it explicitly while live. No trade opens immediately; you still need to add a Hyperliquid API key first.',
|
||||
description: 'Switches to live trading. Auto-Trade is turned OFF automatically — re-enable it explicitly. You still need a Hyperliquid API key before the bot can trade.',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
@@ -360,10 +440,9 @@ export default function BotConfigPanel() {
|
||||
if (!isSubscribed) {
|
||||
return (
|
||||
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your trading bot</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
|
||||
The bot places trades on Hyperliquid with a trade-only API key — it can never withdraw funds.
|
||||
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your bot</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 24 }}>
|
||||
Trade-only API key — no withdrawals. Try Paper first, or Live if you already have a key.
|
||||
</div>
|
||||
|
||||
{/* Paper / Live choice */}
|
||||
@@ -421,8 +500,8 @@ export default function BotConfigPanel() {
|
||||
return (
|
||||
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Sign in to view your settings</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 20 }}>
|
||||
Your settings are private and wallet-bound. Sign once to load them — the permission is cached for 4 minutes so you won't be prompted again while you browse.
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 20 }}>
|
||||
Settings are wallet-private. Sign once to load — cached for 4 min.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button className="btn amber" style={{ padding: '10px 22px', fontSize: 13 }} onClick={handleLoadSettings} disabled={loadState === 'loading'}>
|
||||
@@ -442,6 +521,13 @@ export default function BotConfigPanel() {
|
||||
if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below')
|
||||
if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %')
|
||||
if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %')
|
||||
// Macro Vibes is manage-only through the Telegram /adopt command. If Macro is
|
||||
// the only enabled system and Telegram isn't bound, the bot can't manage any
|
||||
// position — so "ready" would be a lie. (tgBound === null = status unknown /
|
||||
// still loading: don't block on it.)
|
||||
if (macroOn && !trumpOn && tgBound === false) {
|
||||
missingItems.push('connect Telegram (Settings → Telegram) so the bot can manage Macro positions via /adopt')
|
||||
}
|
||||
const botReady = missingItems.length === 0
|
||||
|
||||
// Manual window countdown
|
||||
@@ -461,6 +547,77 @@ export default function BotConfigPanel() {
|
||||
return (
|
||||
<div style={{ marginBottom: 28 }} id="bot-config-panel">
|
||||
|
||||
{/* ── Onboarding stepper ─────────────────────────────────────────────── */}
|
||||
{(!isSubscribed || !(hlApiKeySet || paperMode) || !autoTrade) && (
|
||||
<div className="card" style={{ padding: '14px 18px 16px', marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 14 }}>
|
||||
Setup progress
|
||||
</div>
|
||||
|
||||
{/* Steps row: steps are fixed-width, connectors flex-grow to fill */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 14 }}>
|
||||
{([
|
||||
{ label: 'Subscribe', done: isSubscribed },
|
||||
{ label: 'Add HL key', done: hlApiKeySet || paperMode },
|
||||
{ label: 'Enable Auto-Trade', done: autoTrade },
|
||||
] as const).map((step, i, arr) => {
|
||||
const isActive = !step.done && (i === 0 || arr[i - 1].done)
|
||||
const isLast = i === arr.length - 1
|
||||
return (
|
||||
<div key={step.label} style={{
|
||||
display: 'flex', alignItems: 'flex-start',
|
||||
flex: isLast ? '0 0 auto' : 1, // last step: natural width; others: expand via connector
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{/* Circle + label — fixed, never stretches */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--bg-sunk)',
|
||||
color: step.done ? '#fff' : isActive ? '#000' : 'var(--ink-4)',
|
||||
border: `2px solid ${step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--line)'}`,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{step.done ? '✓' : i + 1}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10, lineHeight: 1.3,
|
||||
textAlign: isLast ? 'right' : 'center',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
color: step.done ? 'var(--up)' : isActive ? 'var(--ink)' : 'var(--ink-4)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
{/* Connector — takes all remaining space between this and next step */}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
flex: 1, height: 2, marginTop: 11,
|
||||
background: step.done ? 'var(--up)' : 'var(--line)',
|
||||
transition: 'background 0.3s',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom progress bar */}
|
||||
<div style={{ height: 3, borderRadius: 999, background: 'var(--bg-sunk)', overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', borderRadius: 999,
|
||||
background: 'var(--up)',
|
||||
width: `${Math.round(([isSubscribed, hlApiKeySet || paperMode, autoTrade].filter(Boolean).length / 3) * 100)}%`,
|
||||
transition: 'width .4s ease',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
|
||||
<div className="card" style={{ padding: '16px 20px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
@@ -481,7 +638,9 @@ export default function BotConfigPanel() {
|
||||
? <span style={{ color: 'var(--up)' }}>📝 Paper mode — no real money involved. Add an API key below to switch to live.</span>
|
||||
: hlApiKeySet && !apiKey
|
||||
? <><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only · cannot withdraw</span></>
|
||||
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
|
||||
: <>Don't have Hyperliquid?{' '}
|
||||
<a href="https://app.hyperliquid.xyz" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber)', textDecoration: 'none' }}>Sign up free ↗</a>
|
||||
{' '}· Then go to API → generate a trade-only key → paste below.</>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Paper mode: show upgrade path instead of key input */}
|
||||
@@ -526,10 +685,74 @@ export default function BotConfigPanel() {
|
||||
))}
|
||||
</div>
|
||||
{keyState === 'err' && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8, paddingLeft: 50 }}>{keyErr}</div>}
|
||||
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Run one small test trade to confirm the live path end-to-end.</div>}
|
||||
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.</div>}
|
||||
{!paperMode && !hlApiKeySet && (
|
||||
<div style={{
|
||||
marginTop: 10, paddingLeft: 50,
|
||||
display: 'flex', alignItems: 'flex-start', gap: 6,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, flexShrink: 0, marginTop: 1 }}>⚠️</span>
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', lineHeight: 1.5 }}>
|
||||
<strong>Do not paste your main wallet private key.</strong>{' '}
|
||||
This field only accepts a Hyperliquid <em>trade-only API key</em> — generate one at{' '}
|
||||
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--down)', textDecorationColor: 'var(--down)' }}>
|
||||
app.hyperliquid.xyz/API
|
||||
</a>
|
||||
{' '}→ “Generate API wallet”. A trade-only key cannot withdraw funds.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
|
||||
{isSubscribed && (hlApiKeySet || paperMode) && (
|
||||
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
|
||||
{autoTrade
|
||||
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
|
||||
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(true)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
|
||||
color: autoTrade ? '#fff' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>ON</button>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(false)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: '1px solid var(--line)',
|
||||
background: !autoTrade ? 'var(--ink)' : 'transparent',
|
||||
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
{atState !== 'idle' && (
|
||||
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
|
||||
{atState === 'signing' ? 'Waiting for wallet signature…'
|
||||
: atState === 'saving' ? 'Saving…'
|
||||
: 'Failed to update — please try again.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
|
||||
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
|
||||
{/* Header */}
|
||||
@@ -560,7 +783,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Per-trade size
|
||||
<span className="hint">Notional in USD — margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
|
||||
<span className="hint">How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -575,7 +798,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Leverage
|
||||
<span className="hint">Event-driven scalp — use lower leverage if unsure</span>
|
||||
<span className="hint">How aggressive the position is. Start low (2–3×) and increase once you've seen the bot trade live.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -584,13 +807,18 @@ export default function BotConfigPanel() {
|
||||
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
|
||||
</div>
|
||||
<span className="slider-readout">{settings.leverage}×</span>
|
||||
{settings.leverage > 10 && (
|
||||
<div className="settings-note warn" style={{ marginTop: 6 }}>
|
||||
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 3–5× until you see how the bot performs live.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
|
||||
<span className="hint">Filter out weak signals. Higher = fewer trades, but only the strongest ones.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -607,7 +835,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Take profit <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
|
||||
<span className="hint">Bot locks in profit when the position gains this much.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -623,7 +851,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
|
||||
<span className="hint">Bot cuts the loss when the position falls this much.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
@@ -655,8 +883,8 @@ export default function BotConfigPanel() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
|
||||
{macroOn
|
||||
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
|
||||
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
|
||||
? 'When a signal fires: open a BTC long on Hyperliquid → type /adopt in the Telegram bot → bot manages the exit for you.'
|
||||
: 'Disabled — Macro Vibes alerts sent without bot management. Enable + set up Telegram to activate.'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch on={macroOn} onChange={v => updateSettings({ macro_enabled: v })} tone="up" />
|
||||
@@ -719,7 +947,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
BTC bottom leverage
|
||||
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
|
||||
<span className="hint">Higher leverage = less room for BTC to drop before the bot starts reducing the position.</span>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="slider-field">
|
||||
@@ -729,9 +957,9 @@ export default function BotConfigPanel() {
|
||||
</div>
|
||||
<span className="slider-readout">{lev}×</span>
|
||||
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
|
||||
At {lev}× it sheds ⅓ near −{(prot * 0.6).toFixed(0)}%, ⅓ near −{(prot * 0.8).toFixed(0)}%, fully out by −{prot.toFixed(0)}%.
|
||||
Exchange liquidation ≈ −{liq.toFixed(0)}%.
|
||||
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
|
||||
{risky
|
||||
? `⚠️ At ${lev}×, BTC only needs to drop ${prot.toFixed(0)}% before the bot fully closes the position. A normal correction can trigger early exits — use lower leverage unless you're comfortable with that.`
|
||||
: `At ${lev}×, the bot has room for a ${prot.toFixed(0)}% BTC drop before closing. It reduces the position gradually as the drawdown deepens, so you don't lose everything at once.`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,7 +979,7 @@ export default function BotConfigPanel() {
|
||||
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Risk limits & schedule</span>
|
||||
<span style={{
|
||||
fontSize: 11, color: 'var(--ink-4)',
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
@@ -770,7 +998,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily trading cap
|
||||
<span className="hint">Optional — bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
|
||||
<span className="hint">Bot stops opening new trades once it has spent this amount in a day.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
|
||||
@@ -792,7 +1020,7 @@ export default function BotConfigPanel() {
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Trading schedule
|
||||
<span className="hint">Bot only accepts signals inside this window. Times in your browser's local timezone. Leave off to trade anytime.</span>
|
||||
<span className="hint">Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:00–02:00) are allowed. Leave off for 24/7.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
|
||||
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
|
||||
@@ -800,46 +1028,48 @@ export default function BotConfigPanel() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="num-field">
|
||||
<span className="prefix">From</span>
|
||||
<input type="datetime-local" lang="en-US" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
<input type="time" value={fromLocal}
|
||||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
|
||||
<div className="num-field">
|
||||
<span className="prefix">Until</span>
|
||||
<input type="datetime-local" lang="en-US" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
|
||||
<input type="time" value={untilLocal}
|
||||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual window */}
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Override window
|
||||
<span className="hint">Open a timed override so the bot accepts signals for the next 1–24 hours — useful for high-conviction catalysts like CPI or FOMC releases.</span>
|
||||
{/* Override window — only relevant when a schedule is set */}
|
||||
{useSchedule && (
|
||||
<div className="form-row" style={{ marginBottom: 16 }}>
|
||||
<div className="form-row-label">
|
||||
Override window
|
||||
<span className="hint">Temporarily bypass your schedule. Useful when a high-conviction event (e.g. CPI, FOMC) happens outside your trading hours.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
{armed ? (
|
||||
<>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}>● Override active — {remainingLabel} remaining</span>
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
|
||||
{mwBusy ? 'Sign…' : 'Cancel'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
[4, 12, 24].map(h => (
|
||||
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
|
||||
{mwBusy && mwState === 'signing' ? 'Sign…' : `+${h}h`}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
{armed ? (
|
||||
<>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}>● Override active — {remainingLabel} remaining</span>
|
||||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
|
||||
{mwBusy ? 'Sign…' : 'Cancel override'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
[1, 4, 24].map(h => (
|
||||
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
|
||||
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
|
||||
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import type { BotTrade, TrumpPost } from '@/types'
|
||||
import type { BotTrade } from '@/types'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
|
||||
// ── Formatters ────────────────────────────────────────────────────────────────
|
||||
@@ -16,7 +16,8 @@ function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
|
||||
return '$' + s
|
||||
}
|
||||
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
|
||||
function fmtHold(s: number) {
|
||||
function fmtHold(s: number | null | undefined) {
|
||||
if (s == null) return '—'
|
||||
if (s < 60) return s + 's'
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return m + 'm'
|
||||
@@ -24,18 +25,36 @@ function fmtHold(s: number) {
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trades: BotTrade[]
|
||||
posts: TrumpPost[]
|
||||
loading: boolean
|
||||
trades: BotTrade[]
|
||||
loading: boolean
|
||||
locked?: boolean // true = waiting for wallet signature, not "no trades"
|
||||
}
|
||||
|
||||
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
|
||||
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
|
||||
const SIDES = ['all', 'long', 'short'] as const
|
||||
|
||||
// Human-readable labels for raw signal-source identifiers. Keeps the trade
|
||||
// history readable for non-technical users (matches PostCards source labels).
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
truth: 'Trump',
|
||||
btc_bottom_reversal: 'BTC Macro Bottom',
|
||||
funding_reversal: 'Funding Reversal',
|
||||
kol_divergence: 'KOL Divergence',
|
||||
sma_reclaim: 'SMA Reclaim',
|
||||
rsi_reversal: 'RSI Reversal',
|
||||
breakout: 'Breakout',
|
||||
adopted: 'Adopted',
|
||||
manual: 'Manual',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
function sourceLabel(src: string): string {
|
||||
return SOURCE_LABEL[src?.toLowerCase()] ?? src
|
||||
}
|
||||
|
||||
const TRADES_PER_PAGE = 25
|
||||
|
||||
export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
export default function TradeTable({ trades, loading, locked = false }: Props) {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [assetFilter, setAssetFilter] = useState('all')
|
||||
@@ -63,12 +82,31 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
return Array.from(set).sort()
|
||||
}, [trades])
|
||||
|
||||
// Reset filters that no longer apply to the loaded trade set. On a wallet
|
||||
// switch the trades prop changes but the local filter state persists, so a
|
||||
// source/asset/side the previous wallet had could stay "stuck" — and when
|
||||
// the new wallet has a single source the breakdown card (sources.length > 1)
|
||||
// disappears, removing the only Clear affordance. Clamping here guarantees
|
||||
// the user always sees their full new history.
|
||||
useEffect(() => {
|
||||
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
|
||||
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
|
||||
}, [sources, assets, sourceFilter, assetFilter])
|
||||
|
||||
// Per-source PnL aggregate — the critical view for "which module makes money".
|
||||
// Computed BEFORE the asset/side filter so the source breakdown reflects the
|
||||
// full universe, not whatever sub-filter is currently applied.
|
||||
// Apply asset/side/paper filters for the source breakdown so the cards stay
|
||||
// consistent with the KPI and table rows below. We intentionally do NOT apply
|
||||
// sourceFilter here — filtering by source would trivially make one card 100%.
|
||||
const filteredForSources = useMemo(() => trades.filter(t => {
|
||||
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
|
||||
if (sideFilter !== 'all' && t.side !== sideFilter) return false
|
||||
if (hidePaper && t.is_paper) return false
|
||||
return true
|
||||
}), [trades, assetFilter, sideFilter, hidePaper])
|
||||
|
||||
const perSource = useMemo(() => {
|
||||
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
|
||||
for (const t of trades) {
|
||||
for (const t of filteredForSources) {
|
||||
const k = t.trigger_source || 'unknown'
|
||||
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
|
||||
acc[k].trades += 1
|
||||
@@ -79,7 +117,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
if (t.is_paper) acc[k].paper += 1
|
||||
}
|
||||
return acc
|
||||
}, [trades])
|
||||
}, [filteredForSources])
|
||||
|
||||
const filtered = useMemo(() => trades.filter(t => {
|
||||
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
|
||||
@@ -98,8 +136,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0)
|
||||
const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length
|
||||
const losses = priced.length - wins
|
||||
const avgHold = filtered.length > 0
|
||||
? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length)
|
||||
const withHold = filtered.filter(t => t.hold_seconds !== null)
|
||||
const avgHold = withHold.length > 0
|
||||
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
|
||||
: 0
|
||||
|
||||
return (
|
||||
@@ -111,7 +150,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
|
||||
}}>
|
||||
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
|
||||
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
@@ -142,7 +181,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
|
||||
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{src}
|
||||
{sourceLabel(src)}
|
||||
{s.paper > 0 && (
|
||||
<span style={{
|
||||
fontSize: 9, marginLeft: 6, padding: '1px 5px',
|
||||
@@ -171,7 +210,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
border: '1px solid var(--line)', borderRadius: 4,
|
||||
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
|
||||
>
|
||||
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
|
||||
{isZh ? `清除(当前为 “${sourceLabel(sourceFilter)}”)` : `Clear (showing “${sourceLabel(sourceFilter)}”)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -249,26 +288,31 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{/* P&L right after Side — it's the column this page exists for.
|
||||
As the last of 8 columns it sat off-screen on phones (the
|
||||
table scrolls inside its card) until the user swiped past
|
||||
entry/exit/hold/trigger to find it. */}
|
||||
<th>{isZh ? '来源' : 'Source'}</th>
|
||||
<th>{isZh ? '资产' : 'Asset'}</th>
|
||||
<th>{isZh ? '方向' : 'Side'}</th>
|
||||
<th>P&L</th>
|
||||
<th>{isZh ? '开仓' : 'Entry'}</th>
|
||||
<th>{isZh ? '平仓' : 'Exit'}</th>
|
||||
<th>{isZh ? '持仓' : 'Hold'}</th>
|
||||
<th>{isZh ? '触发内容' : 'Trigger'}</th>
|
||||
<th>P&L</th>
|
||||
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
|
||||
{isZh ? '没有符合条件的交易。' : 'No trades found'}
|
||||
{locked
|
||||
? (isZh ? '签名解锁后即可查看交易历史。' : 'Sign to unlock your trade history above.')
|
||||
: (isZh ? '没有符合条件的交易。' : 'No trades found')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{pageRows.map(t => {
|
||||
const tp = posts.find(p => p.id === t.trigger_post_id)
|
||||
const roi =
|
||||
t.entry_price && t.exit_price != null
|
||||
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
|
||||
@@ -283,7 +327,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
padding: '2px 7px', borderRadius: 4,
|
||||
background: 'var(--bg-sunk)',
|
||||
}}>
|
||||
{src}
|
||||
{sourceLabel(src)}
|
||||
</span>
|
||||
{t.is_paper && (
|
||||
<span style={{
|
||||
@@ -306,20 +350,8 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
|
||||
</span>
|
||||
</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 }}>
|
||||
{tp ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{tp.text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack" style={{ alignItems: 'flex-end' }}>
|
||||
<div className="stack" style={{ alignItems: 'flex-start' }}>
|
||||
{t.pnl_usd === null || t.pnl_usd === undefined ? (
|
||||
<span
|
||||
style={{ fontSize: 12, color: 'var(--ink-4)' }}
|
||||
@@ -339,6 +371,18 @@ export default function TradeTable({ trades, posts, loading }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
|
||||
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
|
||||
<td style={{ maxWidth: 260 }}>
|
||||
{t.trigger_post_text ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
|
||||
{t.trigger_post_text.slice(0, 60)}…
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnimatedNumber — flashes green/red when its value changes, the way a live
|
||||
* trading terminal ticks. Pure presentational: it renders `display` (already
|
||||
* formatted) but watches `value` (the raw number) to decide flash direction.
|
||||
*
|
||||
* The flash is a CSS class toggled for ~600ms via a keyframe (`tick-flash-up` /
|
||||
* `tick-flash-down` in globals.css). Respects prefers-reduced-motion (the CSS
|
||||
* keyframes are disabled there, so this degrades to a plain number).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
/** Raw numeric value — drives the flash direction when it changes. */
|
||||
value: number | null | undefined
|
||||
/** Pre-formatted string to actually show (e.g. "$72,140"). */
|
||||
display: string
|
||||
/** Extra className(s) for the wrapper. */
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function AnimatedNumber({ value, display, className = '', style }: Props) {
|
||||
const prev = useRef<number | null | undefined>(value)
|
||||
const [dir, setDir] = useState<'up' | 'down' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const p = prev.current
|
||||
if (value != null && p != null && value !== p) {
|
||||
setDir(value > p ? 'up' : 'down')
|
||||
const id = setTimeout(() => setDir(null), 600)
|
||||
prev.current = value
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
prev.current = value
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`tick-num ${dir ? `tick-flash-${dir}` : ''} ${className}`}
|
||||
style={style}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -13,10 +13,12 @@
|
||||
* If you need a paragraph, you're explaining the wrong thing.
|
||||
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
|
||||
* should disappear visually until the user actively scans for help.
|
||||
* * The tooltip uses CSS hover only — no JS positioning, no portals.
|
||||
* Trade-off: it's clipped by the nearest `overflow:hidden` ancestor.
|
||||
* Pages that need tooltips inside a `<table>` cell should add
|
||||
* `overflow: visible` to that row/td.
|
||||
* * The tooltip uses CSS hover positioning — no portals. The only JS is
|
||||
* an on-reveal measurement that clamps the bubble inside the viewport
|
||||
* (a centred bubble on an icon near the screen edge used to hang
|
||||
* off-screen, worst on phones). Trade-off: it's still clipped by the
|
||||
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
|
||||
* a `<table>` cell should add `overflow: visible` to that row/td.
|
||||
*
|
||||
* Usage:
|
||||
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
|
||||
@@ -27,6 +29,7 @@
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useRef } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||
@@ -46,8 +49,28 @@ export default function InfoTip({
|
||||
width = 240,
|
||||
iconStyle,
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
// Clamp the bubble inside the viewport when it's revealed. The bubble is
|
||||
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
|
||||
// Measured at shift=0, then the needed horizontal offset is written to the
|
||||
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
|
||||
function clampIntoViewport() {
|
||||
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
|
||||
if (!bubble) return
|
||||
bubble.style.setProperty('--tip-shift', '0px')
|
||||
const r = bubble.getBoundingClientRect()
|
||||
const vw = document.documentElement.clientWidth
|
||||
const margin = 8
|
||||
let shift = 0
|
||||
if (r.left < margin) shift = margin - r.left
|
||||
else if (r.right > vw - margin) shift = (vw - margin) - r.right
|
||||
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={`infotip infotip-${placement}`}
|
||||
// role=button is a slight overstatement but it lets screen-reader users
|
||||
// know there's a thing to tab to. The actual content is in data-tip and
|
||||
@@ -56,6 +79,9 @@ export default function InfoTip({
|
||||
tabIndex={0}
|
||||
aria-label={text}
|
||||
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
|
||||
onMouseEnter={clampIntoViewport}
|
||||
onFocus={clampIntoViewport}
|
||||
onTouchStart={clampIntoViewport}
|
||||
>
|
||||
<span aria-hidden="true" className="infotip-icon">?</span>
|
||||
<span className="infotip-bubble" role="tooltip">{text}</span>
|
||||
|
||||
@@ -93,7 +93,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
|
||||
const [hover, setHover] = React.useState(false)
|
||||
|
||||
const bg = active
|
||||
? 'var(--brand, #f5a524)'
|
||||
? 'var(--amber)'
|
||||
: hover
|
||||
? 'var(--bg-sunk)'
|
||||
: 'transparent'
|
||||
@@ -105,7 +105,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
|
||||
: 'var(--ink-2)'
|
||||
|
||||
const border = active
|
||||
? '1.5px solid var(--brand, #f5a524)'
|
||||
? '1.5px solid var(--amber)'
|
||||
: '1.5px solid var(--line)'
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
interface TradeAlert {
|
||||
id: number
|
||||
event: 'execution_failed' | 'insufficient_balance' | 'budget_reached'
|
||||
| 'reconcile_drift' | 'circuit_breaker_tripped'
|
||||
asset?: string
|
||||
reason?: string
|
||||
balance_usd?: number
|
||||
required_usd?: number
|
||||
spent_usd?: number
|
||||
cap_usd?: number
|
||||
// reconcile_drift fields (from reconciler.py broadcast)
|
||||
// orphan_hl is a list of {asset, trade_id} dicts, NOT a number
|
||||
orphan_hl?: unknown[]
|
||||
marked_closed?: number
|
||||
ghost_positions?: unknown[]
|
||||
// circuit_breaker_tripped fields (from circuit_breaker.py broadcast)
|
||||
system?: string
|
||||
cb_reason?: string
|
||||
unlock_at?: string
|
||||
}
|
||||
|
||||
// How long each alert stays visible before auto-dismissing.
|
||||
const AUTO_DISMISS_MS = 10_000
|
||||
|
||||
function alertMessage(alert: TradeAlert): string {
|
||||
switch (alert.event) {
|
||||
case 'execution_failed':
|
||||
return `Auto-trade failed${alert.asset ? ` on ${alert.asset}` : ''}${alert.reason ? `: ${alert.reason}` : ''}. Check your HL API key and account status.`
|
||||
case 'insufficient_balance':
|
||||
return `Insufficient balance to open${alert.asset ? ` ${alert.asset}` : ''}: account has $${alert.balance_usd?.toFixed(2) ?? '?'}, trade requires $${alert.required_usd?.toFixed(2) ?? '?'}. Top up your Hyperliquid account.`
|
||||
case 'budget_reached':
|
||||
return `Daily budget reached${alert.asset ? ` (${alert.asset})` : ''}: $${alert.spent_usd?.toFixed(0) ?? '?'} spent of $${alert.cap_usd?.toFixed(0) ?? '?'} cap. No more trades today.`
|
||||
// B47: reconciler detects DB ↔ HL mismatch (ghost/orphan position)
|
||||
case 'reconcile_drift': {
|
||||
const parts = []
|
||||
const orphanCount = (alert.orphan_hl as unknown[])?.length ?? 0
|
||||
if (orphanCount > 0) parts.push(`${orphanCount} orphan(s) on HL`)
|
||||
if (alert.marked_closed) parts.push(`${alert.marked_closed} auto-closed`)
|
||||
if ((alert.ghost_positions as unknown[])?.length) parts.push(`${(alert.ghost_positions as unknown[]).length} ghost(s)`)
|
||||
return `Position drift detected: ${parts.join(', ') || 'mismatch between DB and HL'}. Check your Hyperliquid account.`
|
||||
}
|
||||
// B53: circuit breaker fired — Auto-Trade suspended
|
||||
case 'circuit_breaker_tripped':
|
||||
return `Circuit breaker tripped${alert.system ? ` (${alert.system})` : ''}${alert.cb_reason ? `: ${alert.cb_reason}` : ''}. Auto-Trade suspended — turn it back ON on the Trump page to acknowledge and resume.`
|
||||
default:
|
||||
return 'Auto-trade event — check your account.'
|
||||
}
|
||||
}
|
||||
|
||||
let _seq = 0
|
||||
|
||||
export default function TradeAlertBanner() {
|
||||
const { address } = useAccount()
|
||||
const [alerts, setAlerts] = useState<TradeAlert[]>([])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setAlerts(prev => prev.filter(a => a.id !== id))
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss each alert after AUTO_DISMISS_MS.
|
||||
// Effect re-runs whenever the alert list changes. For each alert we schedule
|
||||
// a timeout; cleanup cancels all pending timers when the list updates or
|
||||
// the component unmounts — prevents a dismissed-then-re-added alert from
|
||||
// triggering a ghost dismiss.
|
||||
useEffect(() => {
|
||||
if (alerts.length === 0) return
|
||||
const timers = alerts.map(a =>
|
||||
window.setTimeout(() => dismiss(a.id), AUTO_DISMISS_MS)
|
||||
)
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [alerts, dismiss])
|
||||
|
||||
useWsSubscribe('trade_alert', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string; event?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
const alert = { id: ++_seq, ...msg } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
// B47: reconcile_drift — DB↔HL position mismatch flagged by the reconciler.
|
||||
useWsSubscribe('reconcile_drift', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
const alert = { id: ++_seq, event: 'reconcile_drift' as const, ...msg } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
// B53: circuit_breaker_tripped — Auto-Trade suspended by the CB.
|
||||
useWsSubscribe('circuit_breaker_tripped', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string; reason?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
// Rename 'reason' → 'cb_reason' to avoid collision with the trade_alert 'reason' field.
|
||||
const { reason: cb_reason, ...rest } = msg
|
||||
const alert = { id: ++_seq, event: 'circuit_breaker_tripped' as const, cb_reason, ...rest } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes tradeAlertIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes tradeAlertOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
position: 'fixed', top: 60, left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 8,
|
||||
width: 'min(480px, calc(100vw - 32px))',
|
||||
pointerEvents: 'none',
|
||||
animation: 'tradeAlertIn 0.2s ease',
|
||||
}}>
|
||||
{alerts.map(alert => (
|
||||
<div key={alert.id} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--down)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,.3)',
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>⚠️</span>
|
||||
<div style={{ flex: 1, fontSize: 12, lineHeight: 1.5, color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--down)', display: 'block', marginBottom: 2 }}>
|
||||
Auto-trade blocked
|
||||
</strong>
|
||||
{alertMessage(alert)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
style={{
|
||||
flexShrink: 0, padding: 0, border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', color: 'var(--ink-4)', fontSize: 16, lineHeight: 1,
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* MobileWalletSheet — bottom-drawer shown on mobile when no injected wallet
|
||||
* is detected. Offers deep links to common mobile wallets so the user can
|
||||
* open MetaMask (or Trust / Coinbase / OKX) and land directly on this dApp.
|
||||
*
|
||||
* Rendered as a fixed overlay + slide-up panel. Backdrop tap dismisses it.
|
||||
* The CSS animation class `mobile-sheet-enter` is defined in globals.css.
|
||||
*
|
||||
* Usage:
|
||||
* <MobileWalletSheet open={open} onClose={() => setOpen(false)} />
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getWalletLinks } from '@/lib/mobileWallet'
|
||||
import type { WalletLink } from '@/lib/mobileWallet'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MobileWalletSheet({ open, onClose }: Props) {
|
||||
const [links, setLinks] = useState<WalletLink[]>([])
|
||||
|
||||
// Build deep links client-side only (needs window.location.href).
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLinks(getWalletLinks(window.location.href))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Lock body scroll while sheet is open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Escape key closes.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
/* Backdrop */
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9000,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'flex-end',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
aria-label="Connect a wallet"
|
||||
>
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="mobile-sheet-enter"
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
padding: '20px 20px 32px',
|
||||
boxShadow: '0 -8px 40px rgba(0,0,0,0.18)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div style={{
|
||||
width: 40, height: 4,
|
||||
background: 'var(--line-2)',
|
||||
borderRadius: 99,
|
||||
margin: '0 auto 20px',
|
||||
}} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 4 }}>
|
||||
Connect a wallet
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||||
Open the dApp inside your wallet's built-in browser to connect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{links.map(link => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--surface)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
onTouchStart={e => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--bg-sunk)'
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 12,
|
||||
background: link.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: link.abbr.length > 2 ? 12 : 20,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{link.abbr}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{link.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{link.hint}</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--ink-3)', flexShrink: 0 }}>
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* WalletConnect note */}
|
||||
<div style={{
|
||||
marginTop: 18,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--bg-sunk)',
|
||||
fontSize: 12,
|
||||
color: 'var(--ink-3)',
|
||||
lineHeight: 1.55,
|
||||
}}>
|
||||
<strong style={{ color: 'var(--ink-2)' }}>Already in your wallet's browser?</strong>
|
||||
{' '}Tap Cancel to dismiss, then use the built-in browser navigation to reload the page — MetaMask should auto-detect the dApp.
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-sunk)',
|
||||
border: '1px solid var(--line)',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-2)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+94
-21
@@ -5,11 +5,18 @@ import type {
|
||||
} from '@/types'
|
||||
import type { SignedEnvelope } from './signedRequest'
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
function getServerApiBase() {
|
||||
return (
|
||||
process.env.API_BASE_URL ||
|
||||
process.env.INTERNAL_API_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
'http://localhost:8000'
|
||||
)
|
||||
}
|
||||
|
||||
function getApiOrigin() {
|
||||
return typeof window === 'undefined'
|
||||
? `${BASE_URL}/api`
|
||||
? `${getServerApiBase()}/api`
|
||||
: '/api/proxy/api'
|
||||
}
|
||||
|
||||
@@ -29,6 +36,66 @@ async function fetchJson<T>(path: string, init?: RequestInit): 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 {
|
||||
items: TrumpPost[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
counts: {
|
||||
all: number
|
||||
actionable: number
|
||||
buy: number
|
||||
short: number
|
||||
off_topic: number
|
||||
}
|
||||
source_counts: {
|
||||
source: string
|
||||
count: number
|
||||
latest: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function getPostsPage(
|
||||
limit = 20,
|
||||
page = 1,
|
||||
source?: string,
|
||||
filters?: {
|
||||
sourceIn?: string[]
|
||||
sourceNotIn?: string[]
|
||||
archiveOnly?: boolean
|
||||
sentiment?: 'bullish' | 'bearish' | 'neutral'
|
||||
signal?: 'buy' | 'short' | 'actionable'
|
||||
aiScoredOnly?: boolean
|
||||
},
|
||||
): Promise<PostListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
})
|
||||
if (source) params.set('source', source)
|
||||
if (filters?.sourceIn?.length) params.set('source_in', filters.sourceIn.join(','))
|
||||
if (filters?.sourceNotIn?.length) params.set('source_not_in', filters.sourceNotIn.join(','))
|
||||
if (filters?.archiveOnly) params.set('archive_only', 'true')
|
||||
if (filters?.sentiment) params.set('sentiment', filters.sentiment)
|
||||
if (filters?.signal) params.set('signal', filters.signal)
|
||||
if (filters?.aiScoredOnly) params.set('ai_scored_only', 'true')
|
||||
const q = `/posts-paged?${params.toString()}`
|
||||
return fetchJson<PostListResponse>(q)
|
||||
}
|
||||
|
||||
export async function getPosts(limit = 20, page = 1, source?: string): Promise<TrumpPost[]> {
|
||||
const q = `/posts?limit=${limit}&page=${page}` + (source ? `&source=${encodeURIComponent(source)}` : '')
|
||||
return fetchJson<TrumpPost[]>(q)
|
||||
@@ -50,22 +117,16 @@ export async function getTrades(
|
||||
limit = 20,
|
||||
page = 1,
|
||||
): Promise<BotTrade[]> {
|
||||
const qs = [
|
||||
`wallet=${wallet.toLowerCase()}`,
|
||||
`ts=${env.timestamp}`,
|
||||
`sig=${encodeURIComponent(env.signature)}`,
|
||||
`limit=${limit}`,
|
||||
`page=${page}`,
|
||||
].join('&')
|
||||
return fetchJson<BotTrade[]>(`/trades?${qs}`)
|
||||
const qs = `wallet=${wallet.toLowerCase()}&limit=${limit}&page=${page}`
|
||||
return fetchJson<BotTrade[]>(`/trades?${qs}`, signedReadInit(env))
|
||||
}
|
||||
|
||||
export async function getPerformance(
|
||||
wallet: string,
|
||||
env: SignedEnvelope,
|
||||
): Promise<BotPerformance> {
|
||||
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
|
||||
return fetchJson<BotPerformance>(`/performance?${qs}`)
|
||||
const qs = `wallet=${wallet.toLowerCase()}`
|
||||
return fetchJson<BotPerformance>(`/performance?${qs}`, signedReadInit(env))
|
||||
}
|
||||
|
||||
export async function subscribe(
|
||||
@@ -139,6 +200,11 @@ export interface UserPublic {
|
||||
circuit_breaker_reason?: string | null
|
||||
/** Master Auto-Trade gate. false (default) = signals shown, not traded. */
|
||||
auto_trade?: boolean
|
||||
/** Per-system enable flags. bot_engine gates System-1 (Trump) on
|
||||
* trump_enabled and System-2 (Macro) on macro_enabled. Auto-Trade ON
|
||||
* with trump_enabled=false still does NOT open on a Trump signal. */
|
||||
trump_enabled?: boolean
|
||||
macro_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface SignalSource {
|
||||
@@ -213,16 +279,16 @@ export async function getOpenPositions(
|
||||
wallet: string,
|
||||
env: SignedEnvelope,
|
||||
): Promise<OpenPositionsResponse> {
|
||||
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
|
||||
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`)
|
||||
const qs = `wallet=${wallet.toLowerCase()}`
|
||||
return fetchJson<OpenPositionsResponse>(`/positions/open?${qs}`, signedReadInit(env))
|
||||
}
|
||||
|
||||
export async function getTodayStats(
|
||||
wallet: string,
|
||||
env: SignedEnvelope,
|
||||
): Promise<TodayStats> {
|
||||
const qs = `wallet=${wallet.toLowerCase()}&ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
|
||||
return fetchJson<TodayStats>(`/positions/today?${qs}`)
|
||||
const qs = `wallet=${wallet.toLowerCase()}`
|
||||
return fetchJson<TodayStats>(`/positions/today?${qs}`, signedReadInit(env))
|
||||
}
|
||||
|
||||
// ─── Scanner control (module 2) ─────────────────────────────────────────────
|
||||
@@ -319,8 +385,7 @@ export async function getUser(
|
||||
wallet: string,
|
||||
env: SignedEnvelope,
|
||||
): Promise<UserData> {
|
||||
const qs = `ts=${env.timestamp}&sig=${encodeURIComponent(env.signature)}`
|
||||
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}?${qs}`)
|
||||
return fetchJson<UserData>(`/user/${wallet.toLowerCase()}`, signedReadInit(env))
|
||||
}
|
||||
|
||||
export interface WindowAccuracy {
|
||||
@@ -403,11 +468,14 @@ export async function setManualWindow(
|
||||
|
||||
// ── KOL module ────────────────────────────────────────────────────
|
||||
export async function getKolPosts(opts: {
|
||||
handle?: string; source?: string; limit?: number; page?: number
|
||||
} = {}): Promise<{ items: KolPostSummary[]; page: number; limit: number }> {
|
||||
handle?: string; source?: string; signalsOnly?: boolean; ticker?: string; days?: number; limit?: number; page?: number
|
||||
} = {}): Promise<{ items: KolPostSummary[]; page: number; limit: number; total: number }> {
|
||||
const p = new URLSearchParams()
|
||||
if (opts.handle) p.set('handle', opts.handle)
|
||||
if (opts.source) p.set('source', opts.source)
|
||||
if (opts.signalsOnly) p.set('signals_only', 'true')
|
||||
if (opts.ticker) p.set('ticker', opts.ticker)
|
||||
if (opts.days) p.set('days', String(opts.days))
|
||||
p.set('limit', String(opts.limit ?? 50))
|
||||
p.set('page', String(opts.page ?? 1))
|
||||
return fetchJson(`/kol/posts?${p.toString()}`)
|
||||
@@ -520,7 +588,12 @@ export interface TelegramStatus {
|
||||
export interface TelegramInitResp {
|
||||
code: string; deep_link: string; expires_in_seconds: number
|
||||
}
|
||||
export async function getTelegramStatus(wallet: string): Promise<TelegramStatus> {
|
||||
export async function getTelegramStatus(wallet: string, env?: SignedEnvelope): Promise<TelegramStatus> {
|
||||
// Pass signature when available so the backend returns full binding details.
|
||||
// Without it the endpoint returns only `bound: boolean` to prevent third-party de-anonymisation.
|
||||
if (env) {
|
||||
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`, signedReadInit(env))
|
||||
}
|
||||
return fetchJson<TelegramStatus>(`/telegram/${wallet}/status`)
|
||||
}
|
||||
// SignedEnvelope is already imported at the top of this file. Below we
|
||||
|
||||
+16
-5
@@ -18,6 +18,11 @@ interface CacheEntry<T> {
|
||||
data: T
|
||||
ts: number
|
||||
revalidating: boolean
|
||||
/** onUpdate callbacks of every caller that hit this key while a background
|
||||
* revalidation was in flight (M9 fix). All are notified when the fetch
|
||||
* resolves — previously only the caller that STARTED the revalidation got
|
||||
* fresh data; everyone else silently kept the stale copy. */
|
||||
subscribers: Array<(fresh: T) => void>
|
||||
}
|
||||
|
||||
const store = new Map<string, CacheEntry<unknown>>()
|
||||
@@ -37,16 +42,22 @@ export async function swrFetch<T>(
|
||||
// Still fresh — return immediately.
|
||||
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) {
|
||||
hit.revalidating = true
|
||||
fetcher()
|
||||
.then(fresh => {
|
||||
store.set(key, { data: fresh, ts: Date.now(), revalidating: false })
|
||||
onUpdate?.(fresh)
|
||||
const subs = hit.subscribers
|
||||
store.set(key, { data: fresh, ts: Date.now(), revalidating: false, subscribers: [] })
|
||||
for (const notify of subs) {
|
||||
try { notify(fresh) } catch { /* one bad subscriber must not starve the rest */ }
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (hit) hit.revalidating = false
|
||||
hit.revalidating = false
|
||||
hit.subscribers = []
|
||||
})
|
||||
}
|
||||
return hit.data
|
||||
@@ -54,7 +65,7 @@ export async function swrFetch<T>(
|
||||
|
||||
// Cache miss — must wait for the first fetch.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mobile wallet detection and deep-link helpers.
|
||||
*
|
||||
* On mobile browsers (Safari / Chrome) there is no injected `window.ethereum`,
|
||||
* so the `injected()` wagmi connector returns nothing and the user sees
|
||||
* "No wallet provider found". The fix is to detect this state early and offer
|
||||
* deep links that open MetaMask / Trust / Coinbase / OKX and land the user
|
||||
* inside the wallet's in-app browser at the current dApp URL.
|
||||
*
|
||||
* Deep-link patterns used:
|
||||
* MetaMask https://metamask.app.link/dapp/<host><path> (universal link → iOS/Android)
|
||||
* Trust https://link.trustwallet.com/open_url?coin_id=60&url=<encoded>
|
||||
* Coinbase https://go.cb-wallet.com/dapp?url=<encoded>
|
||||
* OKX https://www.okx.com/download?deeplink=okx%3A%2F%2Fmain%2Fdapp%2Fbrowser%3Furl%3D<encoded>
|
||||
*
|
||||
* All links open in a new tab (_blank) so if the user doesn't have the app
|
||||
* installed they land on the wallet's download page without breaking nav.
|
||||
*/
|
||||
|
||||
/** True when the JS runtime has access to browser APIs. */
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
|
||||
/**
|
||||
* Rough mobile UA check — covers iOS (iPhone/iPad) and Android.
|
||||
* Intentionally excludes desktop Chrome/Firefox even if the viewport is narrow.
|
||||
*/
|
||||
export function isMobileDevice(): boolean {
|
||||
if (!isBrowser) return false
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an injected EIP-1193 provider is present in the window
|
||||
* (i.e. the user is already inside MetaMask's browser, has a browser extension,
|
||||
* or Coinbase Wallet's in-app browser).
|
||||
*/
|
||||
export function hasInjectedWallet(): boolean {
|
||||
if (!isBrowser) return false
|
||||
// window.ethereum is the canonical EIP-1193 injection point.
|
||||
// Some wallets also inject under their own namespace but all reputable ones
|
||||
// also set window.ethereum (or window.web3 as a fallback).
|
||||
return !!(window as unknown as { ethereum?: unknown }).ethereum
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the user is on mobile AND has no injected wallet — the state
|
||||
* where we should show the mobile wallet picker instead of a bare error.
|
||||
*/
|
||||
export function needsMobileWallet(): boolean {
|
||||
return isMobileDevice() && !hasInjectedWallet()
|
||||
}
|
||||
|
||||
export interface WalletLink {
|
||||
name: string
|
||||
/** Short subtitle shown under the name */
|
||||
hint: string
|
||||
/** URL that opens the wallet app and navigates to `dappUrl` */
|
||||
href: string
|
||||
/** CSS color for the icon background */
|
||||
color: string
|
||||
/** Short letter(s) displayed when no SVG icon is available */
|
||||
abbr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the deep-link set for the given dApp URL.
|
||||
* Pass `window.location.href` (or a canonical URL) as `dappUrl`.
|
||||
*/
|
||||
export function getWalletLinks(dappUrl: string): WalletLink[] {
|
||||
const enc = encodeURIComponent(dappUrl)
|
||||
// MetaMask universal link: strip the protocol and pass host+path
|
||||
// e.g. "https://trumpsignal.com/en" → "trumpsignal.com/en"
|
||||
const hostPath = dappUrl.replace(/^https?:\/\//, '')
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'MetaMask',
|
||||
hint: 'Most popular — iOS & Android',
|
||||
href: `https://metamask.app.link/dapp/${hostPath}`,
|
||||
color: '#E8831D',
|
||||
abbr: '🦊',
|
||||
},
|
||||
{
|
||||
name: 'Trust Wallet',
|
||||
hint: 'Binance-backed, iOS & Android',
|
||||
href: `https://link.trustwallet.com/open_url?coin_id=60&url=${enc}`,
|
||||
color: '#3375BB',
|
||||
abbr: '🛡',
|
||||
},
|
||||
{
|
||||
name: 'Coinbase Wallet',
|
||||
hint: 'No Coinbase account needed',
|
||||
href: `https://go.cb-wallet.com/dapp?url=${enc}`,
|
||||
color: '#1652F0',
|
||||
abbr: '🔵',
|
||||
},
|
||||
{
|
||||
name: 'OKX Wallet',
|
||||
hint: 'iOS & Android',
|
||||
href: `https://www.okx.com/download?deeplink=${encodeURIComponent(`okx://main/dapp/browser?url=${enc}`)}`,
|
||||
color: '#000000',
|
||||
abbr: 'OKX',
|
||||
},
|
||||
]
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import type { TrumpPost } from '@/types'
|
||||
import { getPosts, getPostsPage, type PostListResponse } from './api'
|
||||
|
||||
const LIVE_ARCHIVE_SOURCES = new Set([
|
||||
'truth',
|
||||
'btc_bottom_reversal',
|
||||
'funding_reversal',
|
||||
'kol_divergence',
|
||||
])
|
||||
|
||||
function isAiScored(post: Pick<TrumpPost, 'ai_confidence' | 'ai_reasoning'>): boolean {
|
||||
return (post.ai_confidence ?? 0) > 0 || !!post.ai_reasoning
|
||||
}
|
||||
|
||||
function buildSourceCounts(items: TrumpPost[]): PostListResponse['source_counts'] {
|
||||
const counts = new Map<string, { count: number; latest: string | null }>()
|
||||
for (const post of items) {
|
||||
const hit = counts.get(post.source)
|
||||
if (!hit) {
|
||||
counts.set(post.source, { count: 1, latest: post.published_at })
|
||||
continue
|
||||
}
|
||||
hit.count += 1
|
||||
if (!hit.latest || post.published_at > hit.latest) hit.latest = post.published_at
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([source, meta]) => ({ source, count: meta.count, latest: meta.latest }))
|
||||
.sort((a, b) => b.count - a.count || a.source.localeCompare(b.source))
|
||||
}
|
||||
|
||||
export function buildPostListFallbackResponse(
|
||||
items: TrumpPost[],
|
||||
source: string,
|
||||
limit: number,
|
||||
): PostListResponse {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
limit,
|
||||
counts: {
|
||||
all: items.length,
|
||||
actionable: items.filter(p => p.signal === 'buy' || p.signal === 'short').length,
|
||||
buy: items.filter(p => p.signal === 'buy').length,
|
||||
short: items.filter(p => p.signal === 'short').length,
|
||||
off_topic: items.filter(p => !isAiScored(p)).length,
|
||||
},
|
||||
source_counts: [{ source, count: items.length, latest: items[0]?.published_at ?? null }],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildArchiveFallbackResponse(
|
||||
allPosts: TrumpPost[],
|
||||
page: number,
|
||||
limit: number,
|
||||
source = 'all',
|
||||
): PostListResponse {
|
||||
const archivePosts = allPosts.filter(post => !LIVE_ARCHIVE_SOURCES.has(post.source))
|
||||
const sourceCounts = buildSourceCounts(archivePosts)
|
||||
const filtered = source === 'all'
|
||||
? archivePosts
|
||||
: archivePosts.filter(post => post.source === source)
|
||||
const offset = (page - 1) * limit
|
||||
const items = filtered.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
items,
|
||||
total: filtered.length,
|
||||
page,
|
||||
limit,
|
||||
counts: {
|
||||
all: filtered.length,
|
||||
actionable: filtered.filter(p => p.signal === 'buy' || p.signal === 'short').length,
|
||||
buy: filtered.filter(p => p.signal === 'buy').length,
|
||||
short: filtered.filter(p => p.signal === 'short').length,
|
||||
off_topic: filtered.filter(p => !isAiScored(p)).length,
|
||||
},
|
||||
source_counts: sourceCounts,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInitialPostPage(
|
||||
limit: number,
|
||||
page: number,
|
||||
options: {
|
||||
source?: string
|
||||
filters?: {
|
||||
sourceIn?: string[]
|
||||
sourceNotIn?: string[]
|
||||
archiveOnly?: boolean
|
||||
sentiment?: 'bullish' | 'bearish' | 'neutral'
|
||||
signal?: 'buy' | 'short' | 'actionable'
|
||||
aiScoredOnly?: boolean
|
||||
}
|
||||
legacyFallbackSource?: string
|
||||
legacyFallback?: () => Promise<PostListResponse | null>
|
||||
},
|
||||
): Promise<PostListResponse | null> {
|
||||
return getPostsPage(limit, page, options.source, options.filters).catch(async (e) => {
|
||||
// Only fall back to legacy /posts on 404 (new endpoint not yet deployed).
|
||||
// Non-404 errors (500, network) should NOT silently mask as empty data —
|
||||
// a server-side fallback on 500 produces initial HTML that the client
|
||||
// cannot reproduce on re-fetch (it shows an error instead), causing a
|
||||
// hydration mismatch. Return null so the page renders empty and the
|
||||
// client fetches cleanly on mount.
|
||||
const detail = e instanceof Error ? e.message : ''
|
||||
if (!detail.includes('404')) return null
|
||||
|
||||
if (options.legacyFallback) {
|
||||
return options.legacyFallback()
|
||||
}
|
||||
if (!options.legacyFallbackSource || options.filters?.archiveOnly || options.filters?.sourceIn || options.filters?.sourceNotIn) {
|
||||
return null
|
||||
}
|
||||
const fallbackItems = await getPosts(limit, page, options.legacyFallbackSource).catch(() => null)
|
||||
return fallbackItems ? buildPostListFallbackResponse(fallbackItems, options.legacyFallbackSource, limit) : null
|
||||
})
|
||||
}
|
||||
+25
-4
@@ -1,9 +1,16 @@
|
||||
import { createConfig, createStorage, http } from 'wagmi'
|
||||
import { mainnet } from 'wagmi/chains'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
import { injected, walletConnect } from 'wagmi/connectors'
|
||||
|
||||
export const chains = [mainnet] as const
|
||||
|
||||
// WalletConnect v2 requires a free project ID from https://cloud.walletconnect.com
|
||||
// Set NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID in .env.local to enable:
|
||||
// - QR code scanning (desktop → mobile wallet)
|
||||
// - All WalletConnect-compatible mobile wallets
|
||||
// Without it, only injected (browser extension) wallets are available.
|
||||
const wcProjectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
|
||||
|
||||
export const config = createConfig({
|
||||
chains,
|
||||
connectors: [
|
||||
@@ -12,10 +19,24 @@ export const config = createConfig({
|
||||
// Brave, Frame, Trust, OKX…) fail to connect, and broke in browsers where
|
||||
// window.ethereum isn't MetaMask. wagmi v2 auto-discovers all injected
|
||||
// wallets via EIP-6963 (multiInjectedProviderDiscovery, on by default), so
|
||||
// a generic injected() connector covers them. This still uses the native
|
||||
// injected provider path (NOT the MetaMask SDK), so it avoids the SDK
|
||||
// account-sync bug that motivated the original pin.
|
||||
// a generic injected() connector covers them.
|
||||
injected({ shimDisconnect: false }),
|
||||
|
||||
// WalletConnect v2 — enabled when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is set.
|
||||
// Provides QR-code pairing (desktop ↔ mobile) and all WalletConnect wallets.
|
||||
// Get a free project ID at https://cloud.walletconnect.com
|
||||
...(wcProjectId
|
||||
? [walletConnect({
|
||||
projectId: wcProjectId,
|
||||
metadata: {
|
||||
name: 'Trump Alpha',
|
||||
description: 'Live crypto signals — Trump posts, BTC macro, KOL divergence',
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com',
|
||||
icons: ['https://trumpsignal.com/icon'],
|
||||
},
|
||||
showQrModal: true,
|
||||
})]
|
||||
: []),
|
||||
],
|
||||
transports: {
|
||||
[mainnet.id]: http(),
|
||||
|
||||
+9
-1
@@ -27,7 +27,15 @@ interface WsContextValue {
|
||||
|
||||
const WsContext = createContext<WsContextValue | null>(null)
|
||||
|
||||
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'
|
||||
// In production, NEXT_PUBLIC_WS_URL must be set (e.g. wss://api.trumpsignal.com).
|
||||
// Fallback auto-upgrades ws→wss when the page is served over HTTPS to avoid
|
||||
// mixed-content blocks, and falls back to ws:// for local dev.
|
||||
const _wsEnv = process.env.NEXT_PUBLIC_WS_URL
|
||||
const WS_URL = _wsEnv || (
|
||||
typeof window !== 'undefined' && window.location.protocol === 'https:'
|
||||
? 'wss://localhost:8000'
|
||||
: 'ws://localhost:8000'
|
||||
)
|
||||
|
||||
// Exponential backoff: 2s → 4s → 8s → … capped at 60s, plus ±30% jitter
|
||||
// so a server restart doesn't get hit by all clients at the same instant.
|
||||
|
||||
Generated
+2
-3
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"name": "trumpsignal",
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trumpsignal",
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@parcel/watcher-linux-arm64-glibc": "*",
|
||||
"@rainbow-me/rainbowkit": "^2.1.3",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"lightweight-charts": "^4.1.3",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "trumpsignal",
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
|
||||
@@ -41,7 +41,7 @@ Manage-only: alerts surface; the user opens on Hyperliquid and the bot then
|
||||
manages the exit (5-rung stop ladder, de-risk, pyramid, peak-trail). No auto-open.
|
||||
|
||||
### 3. KOL talks-vs-trades divergence (highest conviction)
|
||||
19 crypto KOL feeds ingested daily via RSS. An NLP step extracts ticker(s),
|
||||
25 crypto KOL feeds ingested daily via RSS. An NLP step extracts ticker(s),
|
||||
directional stance, and conviction. A separate on-chain step diffs each KOL's
|
||||
Ethereum wallet. A DIVERGENCE fires when public stance and wallet action
|
||||
disagree within a ±7-day window (e.g. publicly bullish ETH while selling ETH).
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ Trump Alpha is Endorphin's public signal desk: four independent signal engines a
|
||||
- **BTC Macro Bottom** (long-only): fires when ≥ 2 of 3 agree — AHR999 < 0.45, price ≤ 200-week MA × 1.05, Pi Cycle Bottom. Fires 2–4 times per multi-year cycle.
|
||||
- **BTC Funding Reversal**: fires when 30-day cumulative perp funding crosses ±3% AND the most recent cycles start cooling (mean-reversion bet against crowded positioning).
|
||||
|
||||
3. **KOL Signal** — 19 crypto KOL feeds ingested daily via RSS: Arthur Hayes (BitMEX co-founder), Delphi Digital, Dragonfly Capital, Bankless, Empire (Jason Yanowitz + Santiago Santos), Unchained (Laura Shin), 0xResearch (Blockworks), Lightspeed (Blockworks), Pomp (Anthony Pompliano), The Defiant, Reflexivity Research (Will Clemente), Bell Curve (Multicoin Capital), The Scoop (The Block / Frank Chaparro), TFTC (Marty Bent), checkmate (Glassnode), coinmetrics, willywoo (Willy Woo), glassnode, and Lyn Alden (Lyn Alden Investment Strategy). AI extracts: ticker(s) mentioned, directional stance (buy/sell/bullish/bearish/reduce/mention), and conviction score.
|
||||
3. **KOL Signal** — 25 crypto KOL feeds ingested daily via RSS: Arthur Hayes (BitMEX co-founder), Delphi Digital, Bankless, Empire (Jason Yanowitz + Santiago Santos), Unchained (Laura Shin), 0xResearch (Blockworks), Lightspeed (Blockworks), Pomp (Anthony Pompliano), The Defiant, Reflexivity Research (Will Clemente), Bell Curve (Multicoin Capital), The Scoop (The Block / Frank Chaparro), TFTC (Marty Bent), checkmate (Glassnode), coinmetrics, willywoo (Willy Woo), glassnode, Lyn Alden (Lyn Alden Investment Strategy), The DeFi Edge, Bitcoin Magazine, Deribit Insights, Bitfinex Alpha, Forward Guidance, and more. AI extracts: ticker(s) mentioned, directional stance (buy/sell/bullish/bearish/reduce/mention), and conviction score.
|
||||
|
||||
4. **Talks-vs-Trades Divergence** — The platform's highest-conviction signal. Cross-references each KOL's public posts against their on-chain Ethereum wallet changes within a ±7-day window. A DIVERGENCE fires when a KOL is publicly bullish but their wallet is selling (or vice versa). An ALIGNMENT fires when public stance and on-chain action agree. On-chain action is treated as ground truth — talk is cheap, wallet movements are not.
|
||||
|
||||
@@ -32,7 +32,7 @@ Trump Alpha is Endorphin's public signal desk: four independent signal engines a
|
||||
|
||||
**Talks-vs-Trades divergence**: Comparing a KOL's public verbal position on an asset against what their on-chain wallet actually does within the same ±7-day window. The divergence signal (say one thing, do another) is treated as the higher-conviction indicator — on-chain behavior is harder to fake than a tweet or newsletter.
|
||||
|
||||
**KOL (Key Opinion Leader)**: In crypto, influential analysts, fund managers, podcast hosts, and content creators whose public views measurably move retail sentiment. Trump Alpha tracks 19 of them across Substack, podcast RSS, and blog feeds.
|
||||
**KOL (Key Opinion Leader)**: In crypto, influential analysts, fund managers, podcast hosts, and content creators whose public views measurably move retail sentiment. Trump Alpha tracks 25 of them across Substack, podcast RSS, and blog feeds.
|
||||
|
||||
**Hyperliquid**: A decentralized perpetual futures exchange. Trump Alpha's auto-trader executes all positions there. API keys are trade-only (no withdrawal permissions) and are stored server-side encrypted.
|
||||
|
||||
|
||||
+27
-11
@@ -1,24 +1,38 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
// Wallet-specific fields that must be reset whenever the connected address
|
||||
// changes. Extracted so setWallet can wipe them atomically (B34/B42).
|
||||
const WALLET_RESET = {
|
||||
isSubscribed: false,
|
||||
botReadiness: 'unknown' as const,
|
||||
hlApiKeySet: false,
|
||||
hlApiKeyMasked: null as string | null,
|
||||
paperMode: false,
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
selectedPostId: number | null
|
||||
asset: 'BTC' | 'ETH'
|
||||
asset: string
|
||||
timeframe: '5m' | '15m' | '1H' | '4H' | '1D' | '1W'
|
||||
walletAddress: string | null
|
||||
isSubscribed: boolean
|
||||
botReadiness: 'unknown' | 'saved' | 'verified' | 'ready'
|
||||
hlApiKeySet: boolean // true if user already has a key saved in backend
|
||||
hlApiKeyMasked: string | null // e.g. "...a1b2c3" shown after successful save
|
||||
paperMode: boolean // true = paper trade mode (no HL key needed)
|
||||
// BUG-08 fix: backend now streams SOL/TRUMP/BNB/etc. — use an open Record
|
||||
// so any asset tick can be stored, not just BTC/ETH.
|
||||
livePrices: Record<string, number | null>
|
||||
setSelectedPost: (id: number | null) => void
|
||||
setAsset: (asset: 'BTC' | 'ETH') => void
|
||||
setAsset: (asset: string) => void
|
||||
setTimeframe: (tf: '5m' | '15m' | '1H' | '4H' | '1D' | '1W') => void
|
||||
// B34/B42: setWallet resets ALL wallet-specific fields atomically so
|
||||
// switching wallets never leaks the previous wallet's private state.
|
||||
setWallet: (address: string | null) => void
|
||||
setSubscribed: (subscribed: boolean) => void
|
||||
setBotReadiness: (state: DashboardState['botReadiness']) => void
|
||||
setHlApiKeySet: (set: boolean, masked?: string) => void
|
||||
setPaperMode: (paper: boolean) => void
|
||||
setLivePrice: (asset: string, price: number) => void
|
||||
}
|
||||
|
||||
@@ -27,24 +41,26 @@ export const useDashboardStore = create<DashboardState>((set) => ({
|
||||
asset: 'BTC',
|
||||
timeframe: '4H',
|
||||
walletAddress: null,
|
||||
isSubscribed: false,
|
||||
botReadiness: 'unknown',
|
||||
hlApiKeySet: false,
|
||||
hlApiKeyMasked: null,
|
||||
...WALLET_RESET,
|
||||
livePrices: { BTC: null, ETH: null, SOL: null, TRUMP: null },
|
||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||
setAsset: (asset) => set({ asset }),
|
||||
setTimeframe: (timeframe) => set({ timeframe }),
|
||||
setWallet: (walletAddress) => set({ walletAddress }),
|
||||
// Reset all wallet-specific fields on every address change (including null).
|
||||
setWallet: (walletAddress) => set({ walletAddress, ...WALLET_RESET }),
|
||||
setSubscribed: (isSubscribed) => set({ isSubscribed }),
|
||||
setBotReadiness: (botReadiness) => set({ botReadiness }),
|
||||
setHlApiKeySet: (keySet, masked) =>
|
||||
set((s) => ({
|
||||
set(() => ({
|
||||
hlApiKeySet: keySet,
|
||||
// Preserve existing mask when called without one (e.g. the /public poll
|
||||
// only returns a boolean). Only explicitly clear when keySet=false.
|
||||
hlApiKeyMasked: !keySet ? null : (masked !== undefined ? masked : s.hlApiKeyMasked),
|
||||
// Never preserve the old mask — if the caller doesn't supply one we
|
||||
// don't know the new wallet's mask and must show nothing until the full
|
||||
// /user fetch (BotConfigPanel.applyUserPayload) provides it explicitly.
|
||||
// Old behaviour of "preserve when masked=undefined" was the root cause
|
||||
// of cross-wallet HL key mask leakage (B34 follow-up).
|
||||
hlApiKeyMasked: (keySet && masked !== undefined) ? masked : null,
|
||||
})),
|
||||
setPaperMode: (paperMode) => set({ paperMode }),
|
||||
setLivePrice: (asset, price) =>
|
||||
set((s) => ({ livePrices: { ...s.livePrices, [asset]: price } })),
|
||||
}))
|
||||
|
||||
+15
-5
@@ -46,12 +46,13 @@ export interface BotTrade {
|
||||
asset: string
|
||||
side: 'long' | 'short'
|
||||
entry_price: number
|
||||
exit_price: number
|
||||
pnl_usd: number
|
||||
hold_seconds: number
|
||||
trigger_post_id: number
|
||||
exit_price: number | null // null = externally closed or position still open
|
||||
pnl_usd: number | null // null = unsettled / externally closed
|
||||
hold_seconds: number | null // null = not yet computed
|
||||
trigger_post_id: number | null // null = adopted/manual (no trigger post)
|
||||
opened_at: string
|
||||
closed_at: string
|
||||
closed_at: string | null // null for still-open positions
|
||||
trigger_post_text?: string | null
|
||||
/** Source of the triggering signal — 'truth' | 'breakout' | user's module name. */
|
||||
trigger_source?: string | null
|
||||
/** True iff this was a paper-mode trade (no Hyperliquid call). */
|
||||
@@ -75,8 +76,13 @@ export interface KolTicker {
|
||||
action: KolAction
|
||||
conviction: number
|
||||
quote: string
|
||||
timeframe?: string
|
||||
stance_change?: boolean
|
||||
}
|
||||
|
||||
export type KolTier = 'trade_signal' | 'directional' | 'noise'
|
||||
export type KolPostType = 'original' | 'reply' | 'retweet' | 'quote' | 'thread_cont'
|
||||
|
||||
export interface KolPostSummary {
|
||||
id: number
|
||||
kol_handle: string
|
||||
@@ -88,6 +94,10 @@ export interface KolPostSummary {
|
||||
tickers: KolTicker[]
|
||||
analyzed_at: string | null
|
||||
analysis_model: string | null
|
||||
tier?: KolTier | null
|
||||
post_type?: KolPostType | null
|
||||
talks_vs_trades_flag?: boolean
|
||||
sentiment?: 'bullish' | 'bearish' | 'neutral' | null
|
||||
}
|
||||
|
||||
export interface KolPostDetail extends KolPostSummary {
|
||||
|
||||
Reference in New Issue
Block a user