mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 15:26:55 +08:00
feat: client-side simulation layer for terminal dashboard panels
Adds a local synthetic data engine that can drive all dashboard panels (candles, order book, liq ladder, stats) without a backend connection, plus de-synced beam animation phases in the orchestration topology.
This commit is contained in:
@@ -3,6 +3,9 @@ import useSWR from 'swr'
|
||||
import { api } from '../../lib/api'
|
||||
import type { Kline } from '../../lib/api/data'
|
||||
import { Candles } from './Candles'
|
||||
import { demoSeedPrice } from '../../lib/demo/demoUniverse'
|
||||
|
||||
const drnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
/**
|
||||
* KlineChart shows a live candlestick chart. History is seeded from the backend
|
||||
@@ -29,18 +32,87 @@ interface KlineChartProps {
|
||||
height?: number
|
||||
/** stretch the chart to fill the parent's remaining height */
|
||||
fill?: boolean
|
||||
/** showcase mode — drive a fast synthetic uptrend instead of the live feed */
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
export function KlineChart({ symbol, height = 360, fill = false }: KlineChartProps) {
|
||||
export function KlineChart({ symbol, height = 360, fill = false, demo = false }: KlineChartProps) {
|
||||
const base = baseSymbol(symbol || '')
|
||||
|
||||
// history seed (resynced occasionally; the WS carries the live bar)
|
||||
const { data: seed, isLoading } = useSWR(
|
||||
base ? ['kline', base, INTERVAL] : null,
|
||||
base && !demo ? ['kline', base, INTERVAL] : null,
|
||||
() => api.getKlines(base, INTERVAL, 'hyperliquid', MAX_BARS, true),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false, shouldRetryOnError: false, keepPreviousData: true },
|
||||
)
|
||||
|
||||
// synthetic showcase candles — a fast, gently rising series
|
||||
const [demoCandles, setDemoCandles] = useState<Kline[]>([])
|
||||
useEffect(() => {
|
||||
if (!demo || !base) return
|
||||
const target = demoSeedPrice(base)
|
||||
// A deliberately clean rising series: linear climb + a couple of gentle waves
|
||||
// (natural pullbacks) + tiny noise and small wicks. The last close lands on
|
||||
// `target` so it stays aligned with the order book + cost/liq mark. No bar
|
||||
// rolling (which degraded into flat noise) — the forming bar ticks live and
|
||||
// the whole series subtly refreshes occasionally.
|
||||
const buildSeries = (): Kline[] => {
|
||||
const now = Date.now()
|
||||
const start = target * (1 - drnd(0.013, 0.02))
|
||||
const span = target - start
|
||||
const phase = drnd(0, Math.PI * 2)
|
||||
const bars: Kline[] = []
|
||||
let prevClose = start
|
||||
for (let i = 0; i < MAX_BARS; i++) {
|
||||
const t01 = i / (MAX_BARS - 1)
|
||||
const close = start + span * t01 + span * 0.13 * Math.sin(t01 * 6.5 + phase) + (Math.random() - 0.5) * target * 0.0008
|
||||
const open = i === 0 ? start : prevClose
|
||||
const wick = target * drnd(0.0003, 0.0009)
|
||||
const tt = now - (MAX_BARS - i) * 60_000
|
||||
bars.push({
|
||||
openTime: tt,
|
||||
closeTime: tt + 60_000,
|
||||
open,
|
||||
high: Math.max(open, close) + wick,
|
||||
low: Math.min(open, close) - wick,
|
||||
close,
|
||||
volume: drnd(60, 360),
|
||||
})
|
||||
prevClose = close
|
||||
}
|
||||
const last = bars[bars.length - 1]
|
||||
last.close = target
|
||||
last.high = Math.max(last.high, target)
|
||||
last.low = Math.min(last.low, target)
|
||||
return bars
|
||||
}
|
||||
setDemoCandles(buildSeries())
|
||||
let frame = 0
|
||||
const id = setInterval(() => {
|
||||
frame++
|
||||
if (frame % 100 === 0) {
|
||||
setDemoCandles(buildSeries()) // subtle full refresh (~25s)
|
||||
return
|
||||
}
|
||||
setDemoCandles((prev) => {
|
||||
if (!prev.length) return prev
|
||||
const arr = prev.slice()
|
||||
const i = arr.length - 1
|
||||
const last = { ...arr[i] }
|
||||
// forming bar ticks gently around the current price; clean fixed-size wick
|
||||
const close = target * (1 + drnd(-0.0009, 0.001))
|
||||
const wick = target * 0.0006
|
||||
last.close = close
|
||||
last.high = Math.max(last.open, close) + wick
|
||||
last.low = Math.min(last.open, close) - wick
|
||||
last.volume += drnd(2, 14)
|
||||
arr[i] = last
|
||||
return arr
|
||||
})
|
||||
}, 250)
|
||||
return () => clearInterval(id)
|
||||
}, [demo, base])
|
||||
|
||||
// resolve the Hyperliquid coin id (xyz: dex membership)
|
||||
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
@@ -65,7 +137,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
||||
const [wsLive, setWsLive] = useState(false)
|
||||
const pending = useRef<Kline | null>(null)
|
||||
useEffect(() => {
|
||||
if (!coin) return
|
||||
if (!coin || demo) return
|
||||
setLiveBar(null)
|
||||
let ws: WebSocket | null = null
|
||||
let raf: number | null = null
|
||||
@@ -117,7 +189,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
||||
}, [coin])
|
||||
|
||||
// merge the live bar into the seeded history
|
||||
const candles = useMemo(() => {
|
||||
const realCandles = useMemo(() => {
|
||||
const hist = seed ?? []
|
||||
if (!liveBar) return hist
|
||||
const arr = [...hist]
|
||||
@@ -127,10 +199,11 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
||||
return arr.slice(-MAX_BARS)
|
||||
}, [seed, liveBar])
|
||||
|
||||
const candles = demo ? demoCandles : realCandles
|
||||
const last = candles.length ? candles[candles.length - 1].close : 0
|
||||
const first = candles.length ? candles[0].open : 0
|
||||
const chg = first ? ((last - first) / first) * 100 : 0
|
||||
const live = wsLive && candles.length > 0
|
||||
const live = (demo || wsLive) && candles.length > 0
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'var(--tm-mono)', ...(fill ? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } : {}) }}>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../../lib/api'
|
||||
import type { VergexHeatmapBin } from '../../lib/api/data'
|
||||
import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse'
|
||||
|
||||
const lmRnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
/**
|
||||
* LiquidationMap renders the vergex (claw402) cost / liquidation heatmap as a
|
||||
@@ -44,9 +47,11 @@ interface LiquidationMapProps {
|
||||
marketType?: string
|
||||
/** fixed height of the scrollable ladder (px); auto-centres on the mark */
|
||||
height?: number
|
||||
/** showcase mode — render a synthetic ladder centred on the demo seed price */
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460 }: LiquidationMapProps) {
|
||||
export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460, demo = false }: LiquidationMapProps) {
|
||||
// Synthetic markets live under marketType "hip3_perp"; crypto majors under
|
||||
// "perp". We try the caller's guess first and fall back to the other so the
|
||||
// heatmap resolves for ANY symbol that has one.
|
||||
@@ -54,15 +59,68 @@ export function LiquidationMap({ symbol, marketType = 'hip3_perp', height = 460
|
||||
api.getVergexCostLiquidationHeatmap({ marketType: mt, symbol, chain: 'mainnet', liqBand: '15' })
|
||||
const opts = { refreshInterval: 300000, revalidateOnFocus: false, keepPreviousData: true }
|
||||
|
||||
const primary = useSWR(symbol ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts)
|
||||
const primary = useSWR(symbol && !demo ? ['heatmap', marketType, symbol] : null, () => fetcher(marketType), opts)
|
||||
const primaryHasBins = !!primary.data?.data?.bins?.length
|
||||
const altMt = marketType === 'perp' ? 'hip3_perp' : 'perp'
|
||||
const needAlt = !primaryHasBins && !primary.isLoading && primary.data !== undefined
|
||||
const needAlt = !demo && !primaryHasBins && !primary.isLoading && primary.data !== undefined
|
||||
const alt = useSWR(needAlt && symbol ? ['heatmap', altMt, symbol] : null, () => fetcher(altMt), opts)
|
||||
|
||||
const data = primaryHasBins ? primary.data : alt.data
|
||||
const isLoading = primary.isLoading || (needAlt && alt.isLoading)
|
||||
const error = primaryHasBins ? undefined : alt.error || primary.error
|
||||
// showcase mode: drive a slow ticker so the synthetic ladder gently breathes
|
||||
const [demoFrame, setDemoFrame] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!demo) return
|
||||
const id = setInterval(() => setDemoFrame((f) => f + 1), 420)
|
||||
return () => clearInterval(id)
|
||||
}, [demo])
|
||||
|
||||
// stable base ladder for the active symbol (regenerated only on symbol change),
|
||||
// centred on the same seed price the order book / candles use so all three
|
||||
// price panels stay consistent.
|
||||
const demoBase = useMemo(() => {
|
||||
if (!demo) return null
|
||||
const base = (symbol || 'SP500').toUpperCase().replace(/^XYZ:/, '')
|
||||
const mark = demoSeedPrice(base)
|
||||
const tick = demoTick(mark)
|
||||
const N = 44
|
||||
const scale = mark * 1.4e4
|
||||
const bins = [] as { px: number; lc: number; sc: number; ll: number; sl: number }[]
|
||||
for (let i = -N / 2; i <= N / 2; i++) {
|
||||
const px = +(mark + i * tick * 2).toFixed(tick < 1 ? 3 : 1)
|
||||
const dist = Math.abs(i) / (N / 2)
|
||||
const near = Math.max(0, 1 - dist) ** 1.4
|
||||
const far = dist ** 1.3
|
||||
const below = i < 0
|
||||
bins.push({
|
||||
px,
|
||||
lc: (below ? near : near * 0.18) * scale * lmRnd(0.5, 1),
|
||||
sc: (!below ? near : near * 0.18) * scale * lmRnd(0.5, 1),
|
||||
ll: (below ? far : 0) * scale * lmRnd(0.4, 0.9),
|
||||
sl: (!below ? far : 0) * scale * lmRnd(0.4, 0.9),
|
||||
})
|
||||
}
|
||||
return { base, mark, bins, costAddrs: Math.round(lmRnd(22000, 31000)), liqAddrs: Math.round(lmRnd(16000, 23000)) }
|
||||
}, [demo, symbol])
|
||||
|
||||
// per-frame view: each bin breathes on its own phase (gentle ±, not a refresh)
|
||||
const demoData = useMemo(() => {
|
||||
if (!demoBase) return undefined
|
||||
const f = demoFrame
|
||||
const w = (v: number, amp: number, ph: number) => v * (1 + amp * Math.sin(f * 0.5 + ph))
|
||||
const bins: VergexHeatmapBin[] = demoBase.bins.map((b, i) => ({
|
||||
px: b.px,
|
||||
longCost: w(b.lc, 0.12, i * 0.6),
|
||||
shortCost: w(b.sc, 0.12, i * 0.9 + 1.7),
|
||||
longLiq: w(b.ll, 0.16, i * 0.5 + 3.1),
|
||||
shortLiq: w(b.sl, 0.16, i * 0.8 + 4.6),
|
||||
}) as unknown as VergexHeatmapBin)
|
||||
return {
|
||||
data: { bins, markPrice: demoBase.mark, costAddrs: demoBase.costAddrs, liqAddrs: demoBase.liqAddrs, market: { symbol: demoBase.base } },
|
||||
}
|
||||
}, [demoBase, demoFrame])
|
||||
|
||||
const data = demo ? demoData : primaryHasBins ? primary.data : alt.data
|
||||
const isLoading = demo ? false : primary.isLoading || (needAlt && alt.isLoading)
|
||||
const error = demo ? undefined : primaryHasBins ? undefined : alt.error || primary.error
|
||||
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -45,6 +45,14 @@ function baseSymbol(raw: string): string {
|
||||
return raw.toUpperCase().replace(/^XYZ:/, '').replace(/[-_]/g, '').replace(/(USDT|USDC|USD)$/, '')
|
||||
}
|
||||
|
||||
// stable pseudo-random in [0,1) from a string — gives each beam its own phase so
|
||||
// the funnel flickers randomly (not in lockstep) while staying deterministic.
|
||||
function hash01(s: string): number {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
|
||||
return ((h >>> 0) % 10000) / 10000
|
||||
}
|
||||
|
||||
// evenly spread `count` solid items across `capacity` grid cells
|
||||
function scatter(count: number, capacity: number): Map<number, number> {
|
||||
const m = new Map<number, number>()
|
||||
@@ -175,20 +183,30 @@ export function OrchestrationTopology({ layers, className }: OrchestrationTopolo
|
||||
))}
|
||||
</g>
|
||||
|
||||
{engineTargets.map((n, i) => (
|
||||
<circle key={`b0-${i}`} r={1.8} fill={n.dir === 'short' ? SHORT : LONG}>
|
||||
<animate attributeName="cx" values={`${ENGINE_X};${n.x}`} dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${cy};${n.y}`} dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.9;0.9;0" dur={`${0.5 + (i % 5) * 0.08}s`} begin={`${(i % 8) * 0.07}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
{edges.map((e, i) => (
|
||||
<circle key={`be-${e.key}`} r={2.1} fill={e.dir === 'short' ? SHORT : LONG}>
|
||||
<animate attributeName="cx" values={`${e.x1};${e.x2}`} dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${e.y1};${e.y2}`} dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;1;0" dur={`${0.45 + (i % 4) * 0.08}s`} begin={`${(i % 6) * 0.06}s`} repeatCount="indefinite" />
|
||||
</circle>
|
||||
))}
|
||||
{engineTargets.map((n, i) => {
|
||||
const r = hash01(`e${i}-${n.x}-${n.y}`)
|
||||
const dur = `${(0.42 + r * 0.55).toFixed(2)}s`
|
||||
const begin = `${(r * 1.4).toFixed(2)}s`
|
||||
return (
|
||||
<circle key={`b0-${i}`} r={1.8} fill={n.dir === 'short' ? SHORT : LONG}>
|
||||
<animate attributeName="cx" values={`${ENGINE_X};${n.x}`} dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${cy};${n.y}`} dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.9;0.9;0" dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
</circle>
|
||||
)
|
||||
})}
|
||||
{edges.map((e) => {
|
||||
const r = hash01(e.key)
|
||||
const dur = `${(0.38 + r * 0.6).toFixed(2)}s`
|
||||
const begin = `${(r * 1.6).toFixed(2)}s`
|
||||
return (
|
||||
<circle key={`be-${e.key}`} r={2.1} fill={e.dir === 'short' ? SHORT : LONG}>
|
||||
<animate attributeName="cx" values={`${e.x1};${e.x2}`} dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
<animate attributeName="cy" values={`${e.y1};${e.y2}`} dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="1;1;0" dur={dur} begin={begin} repeatCount="indefinite" />
|
||||
</circle>
|
||||
)
|
||||
})}
|
||||
|
||||
{cellsByLayer.map((layer, li) =>
|
||||
layer.map((cell, ci) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { demoSeedPrice, demoTick } from '../../lib/demo/demoUniverse'
|
||||
|
||||
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||
|
||||
/**
|
||||
* OrderBook renders a live L2 depth ladder for a single instrument, streamed
|
||||
@@ -55,9 +58,11 @@ interface OrderBookProps {
|
||||
symbol: string
|
||||
/** optional entry price to mark the user's position level on the ladder */
|
||||
markPrice?: number
|
||||
/** showcase mode — drive a fast synthetic book instead of the live WS feed */
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
export function OrderBook({ symbol, markPrice }: OrderBookProps) {
|
||||
export function OrderBook({ symbol, markPrice, demo = false }: OrderBookProps) {
|
||||
const base = useMemo(() => baseSymbol(symbol || ''), [symbol])
|
||||
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
||||
const [book, setBook] = useState<BookState | null>(null)
|
||||
@@ -88,10 +93,48 @@ export function OrderBook({ symbol, markPrice }: OrderBookProps) {
|
||||
|
||||
const coin = useMemo(() => resolveCoin(base, xyzSet), [base, xyzSet])
|
||||
|
||||
// synthetic showcase feed — keeps price levels stable for stretches (so each
|
||||
// row flashes independently as its size changes) with a gentle upward drift.
|
||||
useEffect(() => {
|
||||
if (!demo || !base) return
|
||||
const seed = demoSeedPrice(base)
|
||||
const tickSz = demoTick(seed)
|
||||
const dp = tickSz < 1 ? 3 : 1
|
||||
let mid = seed
|
||||
let frame = 0
|
||||
const mkSizes = () =>
|
||||
Array.from({ length: DEPTH }, (_, i) => +(rnd(0.4, 6) * (1 + i * 0.12)).toFixed(3))
|
||||
const askSz = mkSizes()
|
||||
const bidSz = mkSizes()
|
||||
const emit = () => {
|
||||
const asks: Level[] = askSz.map((sz, i) => ({ px: +(mid + tickSz * (i + 1)).toFixed(dp), sz }))
|
||||
const bids: Level[] = bidSz.map((sz, i) => ({ px: +(mid - tickSz * (i + 1)).toFixed(dp), sz }))
|
||||
setBook({ coin: `xyz:${base}`, bids, asks })
|
||||
}
|
||||
setStatus('live')
|
||||
emit()
|
||||
const id = setInterval(() => {
|
||||
frame++
|
||||
const n = 2 + Math.floor(Math.random() * 3)
|
||||
for (let k = 0; k < n; k++) {
|
||||
const arr = Math.random() < 0.5 ? askSz : bidSz
|
||||
const i = Math.floor(Math.random() * DEPTH)
|
||||
arr[i] = +Math.max(0.05, arr[i] * rnd(0.6, 1.5)).toFixed(3)
|
||||
}
|
||||
// gentle mean-reverting wiggle around the seed (NO unbounded drift, so the
|
||||
// order book stays aligned with the cost/liq map + candle over a long run)
|
||||
if (frame % 5 === 0) {
|
||||
mid = +(mid + (seed - mid) * 0.3 + (Math.random() - 0.5) * tickSz * 2).toFixed(dp)
|
||||
}
|
||||
emit()
|
||||
}, 130)
|
||||
return () => clearInterval(id)
|
||||
}, [demo, base])
|
||||
|
||||
// live L2 stream
|
||||
const pending = useRef<BookState | null>(null)
|
||||
useEffect(() => {
|
||||
if (!coin) return
|
||||
if (!coin || demo) return
|
||||
let ws: WebSocket | null = null
|
||||
let raf: number | null = null
|
||||
let retry: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -17,6 +17,7 @@ import { KlineChart } from './KlineChart'
|
||||
import { ExecutionLog } from './ExecutionLog'
|
||||
import { SignalMatrix } from './SignalMatrix'
|
||||
import { RiskRadar } from './RiskRadar'
|
||||
import { useDemoEngine } from '../../lib/demo/useDemoEngine'
|
||||
|
||||
// crypto majors trade on the Hyperliquid main dex (no hip3 cost/liq heatmap);
|
||||
// everything else in the universe is an xyz-dex synthetic market that does.
|
||||
@@ -88,50 +89,77 @@ export function TerminalDashboard({
|
||||
traders,
|
||||
selectedTraderId,
|
||||
onTraderSelect,
|
||||
status,
|
||||
account,
|
||||
positions,
|
||||
decisions,
|
||||
status: propStatus,
|
||||
account: propAccount,
|
||||
positions: propPositions,
|
||||
decisions: propDecisions,
|
||||
}: TerminalDashboardProps) {
|
||||
const traderId = selectedTrader?.trader_id || selectedTraderId
|
||||
useTick(1000)
|
||||
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||
|
||||
const { data: fullStats } = useSWR(
|
||||
const { data: realFullStats } = useSWR(
|
||||
traderId ? ['full-stats', traderId] : null,
|
||||
() => api.getFullStats(traderId!, true),
|
||||
{ refreshInterval: 30000, shouldRetryOnError: false }
|
||||
)
|
||||
const { data: history } = useSWR(
|
||||
const { data: realHistory } = useSWR(
|
||||
traderId ? ['pos-history', traderId] : null,
|
||||
() => api.getPositionHistory(traderId!, 50, true),
|
||||
{ refreshInterval: 60000, shouldRetryOnError: false }
|
||||
)
|
||||
const { data: config } = useSWR(
|
||||
const { data: realConfig } = useSWR(
|
||||
traderId ? ['trader-config', traderId] : null,
|
||||
() => api.getTraderConfig(traderId!, true),
|
||||
{ refreshInterval: 120000, shouldRetryOnError: false }
|
||||
)
|
||||
|
||||
const latest = decisions && decisions.length > 0 ? decisions[0] : undefined
|
||||
const candidateCoins = latest?.candidate_coins ?? []
|
||||
|
||||
const { data: flow } = useSWR(
|
||||
const { data: realFlow } = useSWR(
|
||||
traderId ? ['flow-markets', traderId] : null,
|
||||
() => api.getFlowMarkets(selectedTrader?.ai_model, 'mainnet', '1h', 50, true),
|
||||
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds; the
|
||||
// topology beam animation is client-side and stays fast regardless
|
||||
{ refreshInterval: 300000, shouldRetryOnError: false }
|
||||
)
|
||||
const flowItems = flow?.data?.inflow ?? []
|
||||
|
||||
const { data: signalRank } = useSWR(
|
||||
const { data: realSignalRank } = useSWR(
|
||||
traderId ? ['signal-rank', traderId] : null,
|
||||
() => api.getSignalRanking(selectedTrader?.ai_model, 'mainnet', 'all', 30, true),
|
||||
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds
|
||||
{ refreshInterval: 300000, shouldRetryOnError: false }
|
||||
)
|
||||
|
||||
// Demo / showcase mode for product walkthroughs. Toggle with Shift+D (or the
|
||||
// discreet corner dot). Generates a fast, profitable-looking US-equity dataset
|
||||
// entirely client-side — it never touches the backend or any real account.
|
||||
// When off, real data flows through unchanged.
|
||||
const [demo, setDemo] = useState(false)
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
||||
const el = document.activeElement
|
||||
if (el && /^(input|textarea|select)$/i.test(el.tagName)) return
|
||||
setDemo((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
const sim = useDemoEngine(demo)
|
||||
const on = demo && !!sim
|
||||
|
||||
const status = on ? sim!.status : propStatus
|
||||
const account = on ? sim!.account : propAccount
|
||||
const positions = on ? sim!.positions : propPositions
|
||||
const decisions = on ? sim!.decisions : propDecisions
|
||||
const fullStats = on ? sim!.fullStats : realFullStats
|
||||
const history = on ? sim!.history : realHistory
|
||||
const config = on ? (sim!.config as unknown as typeof realConfig) : realConfig
|
||||
const flow = on ? sim!.flow : realFlow
|
||||
const signalRank = on ? sim!.signalRank : realSignalRank
|
||||
|
||||
const latest = decisions && decisions.length > 0 ? decisions[0] : undefined
|
||||
const candidateCoins = latest?.candidate_coins ?? []
|
||||
const flowItems = flow?.data?.inflow ?? []
|
||||
|
||||
// Both the cost/liq map and the order book follow this symbol so they stay in
|
||||
// sync. The heatmap only covers hip3_perp synthetic markets, so we pick a
|
||||
// synthetic (non-crypto) the bot trades — preferring the BUSIEST one (most
|
||||
@@ -236,6 +264,26 @@ export function TerminalDashboard({
|
||||
|
||||
return (
|
||||
<div className="nofx-terminal" style={{ minHeight: '100vh', padding: 0 }}>
|
||||
{/* discreet, unlabelled showcase toggle (Shift+D also works) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDemo((v) => !v)}
|
||||
aria-label="toggle presentation mode"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
zIndex: 9999,
|
||||
width: 12,
|
||||
height: 12,
|
||||
padding: 0,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: on ? 'var(--tm-up)' : 'rgba(26,24,19,0.2)',
|
||||
opacity: on ? 0.55 : 0.18,
|
||||
}}
|
||||
/>
|
||||
{/* centered, capped content column — no border (keeps it from feeling
|
||||
embedded) but bounded so the aspect-ratio SVGs don't balloon on wide screens */}
|
||||
{navSlot &&
|
||||
@@ -315,19 +363,20 @@ export function TerminalDashboard({
|
||||
back to the other one if the guess is wrong */}
|
||||
<LiquidationMap
|
||||
symbol={activeSym}
|
||||
demo={on}
|
||||
marketType={CRYPTO_MAJORS.has(activeSym) ? 'perp' : 'hip3_perp'}
|
||||
height={ROW1_H - 130}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ ...sc, borderRight: cellBorder, height: ROW1_H, overflow: 'hidden' }}>
|
||||
<OrderBook symbol={activeSym} markPrice={positions?.find((p) => baseLabel(p.symbol) === activeSym)?.entry_price} />
|
||||
<OrderBook symbol={activeSym} demo={on} markPrice={positions?.find((p) => baseLabel(p.symbol) === activeSym)?.entry_price} />
|
||||
</div>
|
||||
<div style={{ ...sc, height: ROW1_H, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<SignalMatrix items={signalRank?.items} active={activeSym} onSelect={setSelectedSym} />
|
||||
<SignalMatrix items={signalRank?.items} max={18} active={activeSym} onSelect={setSelectedSym} />
|
||||
{/* the live K-line always sits under the selector and flexes to fill */}
|
||||
<div className="tm-rule" style={{ margin: '10px 0 8px' }} />
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<KlineChart symbol={activeSym} fill />
|
||||
<KlineChart symbol={activeSym} fill demo={on} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user