mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 02:14:35 +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 { api } from '../../lib/api'
|
||||||
import type { Kline } from '../../lib/api/data'
|
import type { Kline } from '../../lib/api/data'
|
||||||
import { Candles } from './Candles'
|
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
|
* KlineChart shows a live candlestick chart. History is seeded from the backend
|
||||||
@@ -29,18 +32,87 @@ interface KlineChartProps {
|
|||||||
height?: number
|
height?: number
|
||||||
/** stretch the chart to fill the parent's remaining height */
|
/** stretch the chart to fill the parent's remaining height */
|
||||||
fill?: boolean
|
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 || '')
|
const base = baseSymbol(symbol || '')
|
||||||
|
|
||||||
// history seed (resynced occasionally; the WS carries the live bar)
|
// history seed (resynced occasionally; the WS carries the live bar)
|
||||||
const { data: seed, isLoading } = useSWR(
|
const { data: seed, isLoading } = useSWR(
|
||||||
base ? ['kline', base, INTERVAL] : null,
|
base && !demo ? ['kline', base, INTERVAL] : null,
|
||||||
() => api.getKlines(base, INTERVAL, 'hyperliquid', MAX_BARS, true),
|
() => api.getKlines(base, INTERVAL, 'hyperliquid', MAX_BARS, true),
|
||||||
{ refreshInterval: 60000, revalidateOnFocus: false, shouldRetryOnError: false, keepPreviousData: 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)
|
// resolve the Hyperliquid coin id (xyz: dex membership)
|
||||||
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,7 +137,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
|||||||
const [wsLive, setWsLive] = useState(false)
|
const [wsLive, setWsLive] = useState(false)
|
||||||
const pending = useRef<Kline | null>(null)
|
const pending = useRef<Kline | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coin) return
|
if (!coin || demo) return
|
||||||
setLiveBar(null)
|
setLiveBar(null)
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let raf: number | null = null
|
let raf: number | null = null
|
||||||
@@ -117,7 +189,7 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
|||||||
}, [coin])
|
}, [coin])
|
||||||
|
|
||||||
// merge the live bar into the seeded history
|
// merge the live bar into the seeded history
|
||||||
const candles = useMemo(() => {
|
const realCandles = useMemo(() => {
|
||||||
const hist = seed ?? []
|
const hist = seed ?? []
|
||||||
if (!liveBar) return hist
|
if (!liveBar) return hist
|
||||||
const arr = [...hist]
|
const arr = [...hist]
|
||||||
@@ -127,10 +199,11 @@ export function KlineChart({ symbol, height = 360, fill = false }: KlineChartPro
|
|||||||
return arr.slice(-MAX_BARS)
|
return arr.slice(-MAX_BARS)
|
||||||
}, [seed, liveBar])
|
}, [seed, liveBar])
|
||||||
|
|
||||||
|
const candles = demo ? demoCandles : realCandles
|
||||||
const last = candles.length ? candles[candles.length - 1].close : 0
|
const last = candles.length ? candles[candles.length - 1].close : 0
|
||||||
const first = candles.length ? candles[0].open : 0
|
const first = candles.length ? candles[0].open : 0
|
||||||
const chg = first ? ((last - first) / first) * 100 : 0
|
const chg = first ? ((last - first) / first) * 100 : 0
|
||||||
const live = wsLive && candles.length > 0
|
const live = (demo || wsLive) && candles.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'var(--tm-mono)', ...(fill ? { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 } : {}) }}>
|
<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 useSWR from 'swr'
|
||||||
import { api } from '../../lib/api'
|
import { api } from '../../lib/api'
|
||||||
import type { VergexHeatmapBin } from '../../lib/api/data'
|
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
|
* LiquidationMap renders the vergex (claw402) cost / liquidation heatmap as a
|
||||||
@@ -44,9 +47,11 @@ interface LiquidationMapProps {
|
|||||||
marketType?: string
|
marketType?: string
|
||||||
/** fixed height of the scrollable ladder (px); auto-centres on the mark */
|
/** fixed height of the scrollable ladder (px); auto-centres on the mark */
|
||||||
height?: number
|
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
|
// 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
|
// "perp". We try the caller's guess first and fall back to the other so the
|
||||||
// heatmap resolves for ANY symbol that has one.
|
// 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' })
|
api.getVergexCostLiquidationHeatmap({ marketType: mt, symbol, chain: 'mainnet', liqBand: '15' })
|
||||||
const opts = { refreshInterval: 300000, revalidateOnFocus: false, keepPreviousData: true }
|
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 primaryHasBins = !!primary.data?.data?.bins?.length
|
||||||
const altMt = marketType === 'perp' ? 'hip3_perp' : 'perp'
|
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 alt = useSWR(needAlt && symbol ? ['heatmap', altMt, symbol] : null, () => fetcher(altMt), opts)
|
||||||
|
|
||||||
const data = primaryHasBins ? primary.data : alt.data
|
// showcase mode: drive a slow ticker so the synthetic ladder gently breathes
|
||||||
const isLoading = primary.isLoading || (needAlt && alt.isLoading)
|
const [demoFrame, setDemoFrame] = useState(0)
|
||||||
const error = primaryHasBins ? undefined : alt.error || primary.error
|
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 [hover, setHover] = useState<number | null>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(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)$/, '')
|
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
|
// evenly spread `count` solid items across `capacity` grid cells
|
||||||
function scatter(count: number, capacity: number): Map<number, number> {
|
function scatter(count: number, capacity: number): Map<number, number> {
|
||||||
const m = new Map<number, number>()
|
const m = new Map<number, number>()
|
||||||
@@ -175,20 +183,30 @@ export function OrchestrationTopology({ layers, className }: OrchestrationTopolo
|
|||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
{engineTargets.map((n, i) => (
|
{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}>
|
<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="cx" values={`${ENGINE_X};${n.x}`} dur={dur} begin={begin} 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="cy" values={`${cy};${n.y}`} dur={dur} begin={begin} 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" />
|
<animate attributeName="opacity" values="0.9;0.9;0" dur={dur} begin={begin} repeatCount="indefinite" />
|
||||||
</circle>
|
</circle>
|
||||||
))}
|
)
|
||||||
{edges.map((e, i) => (
|
})}
|
||||||
|
{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}>
|
<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="cx" values={`${e.x1};${e.x2}`} dur={dur} begin={begin} 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="cy" values={`${e.y1};${e.y2}`} dur={dur} begin={begin} 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" />
|
<animate attributeName="opacity" values="1;1;0" dur={dur} begin={begin} repeatCount="indefinite" />
|
||||||
</circle>
|
</circle>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{cellsByLayer.map((layer, li) =>
|
{cellsByLayer.map((layer, li) =>
|
||||||
layer.map((cell, ci) => {
|
layer.map((cell, ci) => {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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
|
* OrderBook renders a live L2 depth ladder for a single instrument, streamed
|
||||||
@@ -55,9 +58,11 @@ interface OrderBookProps {
|
|||||||
symbol: string
|
symbol: string
|
||||||
/** optional entry price to mark the user's position level on the ladder */
|
/** optional entry price to mark the user's position level on the ladder */
|
||||||
markPrice?: number
|
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 base = useMemo(() => baseSymbol(symbol || ''), [symbol])
|
||||||
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
const [xyzSet, setXyzSet] = useState<Set<string>>(new Set())
|
||||||
const [book, setBook] = useState<BookState | null>(null)
|
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])
|
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
|
// live L2 stream
|
||||||
const pending = useRef<BookState | null>(null)
|
const pending = useRef<BookState | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!coin) return
|
if (!coin || demo) return
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let raf: number | null = null
|
let raf: number | null = null
|
||||||
let retry: ReturnType<typeof setTimeout> | null = null
|
let retry: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { KlineChart } from './KlineChart'
|
|||||||
import { ExecutionLog } from './ExecutionLog'
|
import { ExecutionLog } from './ExecutionLog'
|
||||||
import { SignalMatrix } from './SignalMatrix'
|
import { SignalMatrix } from './SignalMatrix'
|
||||||
import { RiskRadar } from './RiskRadar'
|
import { RiskRadar } from './RiskRadar'
|
||||||
|
import { useDemoEngine } from '../../lib/demo/useDemoEngine'
|
||||||
|
|
||||||
// crypto majors trade on the Hyperliquid main dex (no hip3 cost/liq heatmap);
|
// 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.
|
// everything else in the universe is an xyz-dex synthetic market that does.
|
||||||
@@ -88,50 +89,77 @@ export function TerminalDashboard({
|
|||||||
traders,
|
traders,
|
||||||
selectedTraderId,
|
selectedTraderId,
|
||||||
onTraderSelect,
|
onTraderSelect,
|
||||||
status,
|
status: propStatus,
|
||||||
account,
|
account: propAccount,
|
||||||
positions,
|
positions: propPositions,
|
||||||
decisions,
|
decisions: propDecisions,
|
||||||
}: TerminalDashboardProps) {
|
}: TerminalDashboardProps) {
|
||||||
const traderId = selectedTrader?.trader_id || selectedTraderId
|
const traderId = selectedTrader?.trader_id || selectedTraderId
|
||||||
useTick(1000)
|
useTick(1000)
|
||||||
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
const clock = new Date().toLocaleTimeString('en-GB', { hour12: false })
|
||||||
|
|
||||||
const { data: fullStats } = useSWR(
|
const { data: realFullStats } = useSWR(
|
||||||
traderId ? ['full-stats', traderId] : null,
|
traderId ? ['full-stats', traderId] : null,
|
||||||
() => api.getFullStats(traderId!, true),
|
() => api.getFullStats(traderId!, true),
|
||||||
{ refreshInterval: 30000, shouldRetryOnError: false }
|
{ refreshInterval: 30000, shouldRetryOnError: false }
|
||||||
)
|
)
|
||||||
const { data: history } = useSWR(
|
const { data: realHistory } = useSWR(
|
||||||
traderId ? ['pos-history', traderId] : null,
|
traderId ? ['pos-history', traderId] : null,
|
||||||
() => api.getPositionHistory(traderId!, 50, true),
|
() => api.getPositionHistory(traderId!, 50, true),
|
||||||
{ refreshInterval: 60000, shouldRetryOnError: false }
|
{ refreshInterval: 60000, shouldRetryOnError: false }
|
||||||
)
|
)
|
||||||
const { data: config } = useSWR(
|
const { data: realConfig } = useSWR(
|
||||||
traderId ? ['trader-config', traderId] : null,
|
traderId ? ['trader-config', traderId] : null,
|
||||||
() => api.getTraderConfig(traderId!, true),
|
() => api.getTraderConfig(traderId!, true),
|
||||||
{ refreshInterval: 120000, shouldRetryOnError: false }
|
{ refreshInterval: 120000, shouldRetryOnError: false }
|
||||||
)
|
)
|
||||||
|
const { data: realFlow } = useSWR(
|
||||||
const latest = decisions && decisions.length > 0 ? decisions[0] : undefined
|
|
||||||
const candidateCoins = latest?.candidate_coins ?? []
|
|
||||||
|
|
||||||
const { data: flow } = useSWR(
|
|
||||||
traderId ? ['flow-markets', traderId] : null,
|
traderId ? ['flow-markets', traderId] : null,
|
||||||
() => api.getFlowMarkets(selectedTrader?.ai_model, 'mainnet', '1h', 50, true),
|
() => api.getFlowMarkets(selectedTrader?.ai_model, 'mainnet', '1h', 50, true),
|
||||||
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds; the
|
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds; the
|
||||||
// topology beam animation is client-side and stays fast regardless
|
// topology beam animation is client-side and stays fast regardless
|
||||||
{ refreshInterval: 300000, shouldRetryOnError: false }
|
{ refreshInterval: 300000, shouldRetryOnError: false }
|
||||||
)
|
)
|
||||||
const flowItems = flow?.data?.inflow ?? []
|
const { data: realSignalRank } = useSWR(
|
||||||
|
|
||||||
const { data: signalRank } = useSWR(
|
|
||||||
traderId ? ['signal-rank', traderId] : null,
|
traderId ? ['signal-rank', traderId] : null,
|
||||||
() => api.getSignalRanking(selectedTrader?.ai_model, 'mainnet', 'all', 30, true),
|
() => api.getSignalRanking(selectedTrader?.ai_model, 'mainnet', 'all', 30, true),
|
||||||
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds
|
// paid x402 endpoint — poll slowly (5m) to conserve claw402 funds
|
||||||
{ refreshInterval: 300000, shouldRetryOnError: false }
|
{ 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
|
// 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
|
// sync. The heatmap only covers hip3_perp synthetic markets, so we pick a
|
||||||
// synthetic (non-crypto) the bot trades — preferring the BUSIEST one (most
|
// synthetic (non-crypto) the bot trades — preferring the BUSIEST one (most
|
||||||
@@ -236,6 +264,26 @@ export function TerminalDashboard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="nofx-terminal" style={{ minHeight: '100vh', padding: 0 }}>
|
<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
|
{/* centered, capped content column — no border (keeps it from feeling
|
||||||
embedded) but bounded so the aspect-ratio SVGs don't balloon on wide screens */}
|
embedded) but bounded so the aspect-ratio SVGs don't balloon on wide screens */}
|
||||||
{navSlot &&
|
{navSlot &&
|
||||||
@@ -315,19 +363,20 @@ export function TerminalDashboard({
|
|||||||
back to the other one if the guess is wrong */}
|
back to the other one if the guess is wrong */}
|
||||||
<LiquidationMap
|
<LiquidationMap
|
||||||
symbol={activeSym}
|
symbol={activeSym}
|
||||||
|
demo={on}
|
||||||
marketType={CRYPTO_MAJORS.has(activeSym) ? 'perp' : 'hip3_perp'}
|
marketType={CRYPTO_MAJORS.has(activeSym) ? 'perp' : 'hip3_perp'}
|
||||||
height={ROW1_H - 130}
|
height={ROW1_H - 130}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...sc, borderRight: cellBorder, height: ROW1_H, overflow: 'hidden' }}>
|
<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>
|
||||||
<div style={{ ...sc, height: ROW1_H, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<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 */}
|
{/* the live K-line always sits under the selector and flexes to fill */}
|
||||||
<div className="tm-rule" style={{ margin: '10px 0 8px' }} />
|
<div className="tm-rule" style={{ margin: '10px 0 8px' }} />
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
<KlineChart symbol={activeSym} fill />
|
<KlineChart symbol={activeSym} fill demo={on} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
web/src/lib/demo/demoUniverse.ts
Normal file
47
web/src/lib/demo/demoUniverse.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Shared constants for the dashboard demo/showcase mode. US-equity-led synthetic
|
||||||
|
* universe (these are real xyz-dex markets so the cost/liq heatmap still resolves)
|
||||||
|
* plus plausible seed prices for the synthetic order book / candle feeds.
|
||||||
|
*
|
||||||
|
* This drives the "Demo" presentation mode only. It never touches the backend,
|
||||||
|
* the live trader, or any real account — it is purely a client-side animation
|
||||||
|
* layer for product walkthroughs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEMO_UNIVERSE = [
|
||||||
|
// longs (bullish book)
|
||||||
|
'SP500', 'NVDA', 'MU', 'GOOGL', 'TSM', 'META', 'AMD', 'AAPL', 'MSFT', 'AMZN',
|
||||||
|
'NFLX', 'TSLA', 'AVGO', 'NBIS', 'XYZ100', 'PLTR', 'TXN', 'LRCX', 'AMAT', 'MRVL',
|
||||||
|
// short book (bearish names — see SHORT_SET in the engine)
|
||||||
|
'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM',
|
||||||
|
'HOOD', 'SNOW',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Lead instrument for the price panels (real heatmap + resolvable book/candles).
|
||||||
|
export const DEMO_ACTIVE_SYMBOL = 'SP500'
|
||||||
|
|
||||||
|
const DEMO_SEED_PX: Record<string, number> = {
|
||||||
|
SP500: 6485, NVDA: 184.2, MU: 142.6, GOOGL: 351.8, TSM: 451.3, META: 723.5,
|
||||||
|
AMD: 168.4, AAPL: 245.9, MSFT: 498.2, AMZN: 228.4, NFLX: 921.5, TSLA: 412.8,
|
||||||
|
AVGO: 358.1, NBIS: 266.1, XYZ100: 1182, PLTR: 78.4, TXN: 205.3, LRCX: 102.6,
|
||||||
|
AMAT: 218.7, MRVL: 118.2, INTC: 41.2, SPCX: 64.3, SKHX: 88.7, SMCI: 44.1,
|
||||||
|
ARM: 162.4, QCOM: 178.9, COIN: 312.5, ORCL: 192.3, DRAM: 72.4, CRM: 342.1,
|
||||||
|
HOOD: 58.6, SNOW: 198.4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable plausible seed price for a base symbol (deterministic fallback). */
|
||||||
|
export function demoSeedPrice(base: string): number {
|
||||||
|
const b = base.toUpperCase().replace(/^XYZ:/, '')
|
||||||
|
if (DEMO_SEED_PX[b]) return DEMO_SEED_PX[b]
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < b.length; i++) h = (h * 31 + b.charCodeAt(i)) % 100000
|
||||||
|
return 40 + (h % 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reasonable tick size for a price level given its magnitude. */
|
||||||
|
export function demoTick(px: number): number {
|
||||||
|
if (px >= 1000) return 1
|
||||||
|
if (px >= 100) return 0.1
|
||||||
|
if (px >= 10) return 0.05
|
||||||
|
return 0.01
|
||||||
|
}
|
||||||
456
web/src/lib/demo/useDemoEngine.ts
Normal file
456
web/src/lib/demo/useDemoEngine.ts
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import type {
|
||||||
|
AccountInfo,
|
||||||
|
Position,
|
||||||
|
DecisionRecord,
|
||||||
|
SystemStatus,
|
||||||
|
TraderFullStats,
|
||||||
|
PositionHistoryResponse,
|
||||||
|
HistoricalPosition,
|
||||||
|
SymbolStats,
|
||||||
|
} from '../../types'
|
||||||
|
import type {
|
||||||
|
FlowMarketsResponse,
|
||||||
|
FlowMarketItem,
|
||||||
|
SignalRankingResponse,
|
||||||
|
SignalRankItem,
|
||||||
|
} from '../api/data'
|
||||||
|
import { DEMO_UNIVERSE, DEMO_ACTIVE_SYMBOL, demoSeedPrice } from './demoUniverse'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDemoEngine — a client-side showcase data generator. When `active`, it
|
||||||
|
* synthesises a fast-evolving, profitable-looking US-equity trading dataset that
|
||||||
|
* mirrors the real dashboard data shapes, so every panel animates for a product
|
||||||
|
* walkthrough. Returns null when inactive (the dashboard then uses real data).
|
||||||
|
*
|
||||||
|
* No network, no backend, no real account — pure presentation layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TICK_MS = 200
|
||||||
|
const INITIAL = 1_000_000
|
||||||
|
|
||||||
|
export interface DemoDataset {
|
||||||
|
status: SystemStatus
|
||||||
|
account: AccountInfo
|
||||||
|
positions: Position[]
|
||||||
|
decisions: DecisionRecord[]
|
||||||
|
fullStats: TraderFullStats
|
||||||
|
history: PositionHistoryResponse
|
||||||
|
config: {
|
||||||
|
scan_interval_minutes: number
|
||||||
|
ai_model: string
|
||||||
|
strategy_name: string
|
||||||
|
btc_eth_leverage: number
|
||||||
|
altcoin_leverage: number
|
||||||
|
}
|
||||||
|
flow: FlowMarketsResponse
|
||||||
|
signalRank: SignalRankingResponse
|
||||||
|
activeSymbol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PosState {
|
||||||
|
symbol: string
|
||||||
|
side: 'long' | 'short'
|
||||||
|
entry: number
|
||||||
|
mark: number
|
||||||
|
qty: number
|
||||||
|
lev: number
|
||||||
|
}
|
||||||
|
interface TradeState {
|
||||||
|
id: number
|
||||||
|
symbol: string
|
||||||
|
side: 'long' | 'short'
|
||||||
|
entry: number
|
||||||
|
exit: number
|
||||||
|
qty: number
|
||||||
|
pnl: number
|
||||||
|
fee: number
|
||||||
|
lev: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimState {
|
||||||
|
frame: number
|
||||||
|
cycle: number
|
||||||
|
realized: number
|
||||||
|
fee: number
|
||||||
|
wins: number
|
||||||
|
losses: number
|
||||||
|
grossWin: number
|
||||||
|
grossLoss: number
|
||||||
|
nextId: number
|
||||||
|
decisionTs: number
|
||||||
|
positions: PosState[]
|
||||||
|
trades: TradeState[]
|
||||||
|
// per-symbol flow noise so the bars jiggle independently
|
||||||
|
flowNet: Record<string, number>
|
||||||
|
signalScore: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rnd = (a: number, b: number) => a + Math.random() * (b - a)
|
||||||
|
const pick = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
|
||||||
|
// A fixed "short book" — these symbols are short EVERYWHERE (flow outflow,
|
||||||
|
// bearish signal, short positions, decision candidates) so the topology's
|
||||||
|
// short row carries connected flow lines through every layer. The rest are long.
|
||||||
|
const SHORT_SET = new Set([
|
||||||
|
'INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM', 'COIN', 'ORCL', 'DRAM', 'CRM', 'HOOD', 'SNOW',
|
||||||
|
])
|
||||||
|
const LONG_POOL = DEMO_UNIVERSE.filter((s) => !SHORT_SET.has(s))
|
||||||
|
|
||||||
|
function sideFor(symbol: string): 'long' | 'short' {
|
||||||
|
return SHORT_SET.has(symbol.toUpperCase()) ? 'short' : 'long'
|
||||||
|
}
|
||||||
|
|
||||||
|
function newPosition(symbol: string): PosState {
|
||||||
|
const entry = demoSeedPrice(symbol) * rnd(0.985, 1.015)
|
||||||
|
const side = sideFor(symbol)
|
||||||
|
const lev = pick([10, 10, 15, 20, 20])
|
||||||
|
const notional = rnd(35_000, 110_000)
|
||||||
|
return { symbol, side, entry, mark: entry, qty: notional / entry, lev }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initState(): SimState {
|
||||||
|
// index 0 = lead (drives the price panels). Then a deep US-equity book — a
|
||||||
|
// long bias plus a sizable short book so the topology fills both rows densely.
|
||||||
|
const longs = [DEMO_ACTIVE_SYMBOL, 'NVDA', 'GOOGL', 'TSM', 'META', 'AMD', 'MSFT', 'AMZN', 'AVGO']
|
||||||
|
const shorts = ['INTC', 'SPCX', 'SKHX', 'SMCI', 'ARM', 'QCOM']
|
||||||
|
const positions = [...longs, ...shorts].map((s) => {
|
||||||
|
const p = newPosition(s)
|
||||||
|
// start each slightly in profit so the board opens green
|
||||||
|
const fav = rnd(0.006, 0.03)
|
||||||
|
p.mark = p.side === 'long' ? p.entry * (1 + fav) : p.entry * (1 - fav)
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
const flowNet: Record<string, number> = {}
|
||||||
|
const signalScore: Record<string, number> = {}
|
||||||
|
DEMO_UNIVERSE.forEach((s, i) => {
|
||||||
|
flowNet[s] = rnd(120_000, 2_400_000) * (1 - i / (DEMO_UNIVERSE.length + 4))
|
||||||
|
// short book scores negative (bearish), everything else positive (bullish)
|
||||||
|
signalScore[s] = SHORT_SET.has(s) ? rnd(-1.6, -0.4) : rnd(0.4, 2.0)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
frame: 0,
|
||||||
|
cycle: 1,
|
||||||
|
realized: 312_000,
|
||||||
|
fee: 2840,
|
||||||
|
wins: 412,
|
||||||
|
losses: 214,
|
||||||
|
grossWin: 690_000,
|
||||||
|
grossLoss: 286_000,
|
||||||
|
nextId: 5000,
|
||||||
|
decisionTs: Date.now(),
|
||||||
|
positions,
|
||||||
|
trades: [],
|
||||||
|
flowNet,
|
||||||
|
signalScore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upnl(p: PosState): number {
|
||||||
|
const notional = p.qty * p.entry
|
||||||
|
const move = (p.mark - p.entry) / p.entry
|
||||||
|
return notional * move * (p.side === 'long' ? 1 : -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(S: SimState) {
|
||||||
|
S.frame++
|
||||||
|
|
||||||
|
// drift each position's mark with a favourable bias so the book trends green
|
||||||
|
for (const p of S.positions) {
|
||||||
|
const bias = p.side === 'long' ? 0.00028 : -0.00028
|
||||||
|
p.mark *= 1 + bias + rnd(-0.0011, 0.0011)
|
||||||
|
// keep within a believable favourable band of entry
|
||||||
|
const move = (p.mark - p.entry) / p.entry
|
||||||
|
const dir = p.side === 'long' ? 1 : -1
|
||||||
|
const signed = move * dir
|
||||||
|
if (signed < -0.014) p.mark = p.entry * (1 - dir * 0.014)
|
||||||
|
if (signed > 0.075) p.mark = p.entry * (1 + dir * 0.075)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roll a winning trade every ~16 frames. Only rotate a non-lead LONG slot:
|
||||||
|
// index 0 (lead, drives price panels) and the short book stay fixed so the
|
||||||
|
// order book stays on one symbol and the topology keeps its short flow lines.
|
||||||
|
const rotatable = S.positions
|
||||||
|
.map((p, i) => ({ p, i }))
|
||||||
|
.filter((x) => x.i !== 0 && x.p.side === 'long')
|
||||||
|
if (S.frame % 16 === 0 && rotatable.length) {
|
||||||
|
let bestIdx = rotatable[0].i
|
||||||
|
for (const x of rotatable) {
|
||||||
|
if (upnl(x.p) > upnl(S.positions[bestIdx])) bestIdx = x.i
|
||||||
|
}
|
||||||
|
const isWin = Math.random() < 0.7
|
||||||
|
const closed = S.positions[bestIdx]
|
||||||
|
const raw = upnl(closed)
|
||||||
|
const pnl = isWin ? Math.max(Math.abs(raw), rnd(600, 5200)) : -rnd(250, 2100)
|
||||||
|
const fee = rnd(12, 95)
|
||||||
|
S.trades.unshift({
|
||||||
|
id: S.nextId++,
|
||||||
|
symbol: closed.symbol,
|
||||||
|
side: closed.side,
|
||||||
|
entry: closed.entry,
|
||||||
|
exit: closed.mark,
|
||||||
|
qty: closed.qty,
|
||||||
|
pnl,
|
||||||
|
fee,
|
||||||
|
lev: closed.lev,
|
||||||
|
})
|
||||||
|
if (S.trades.length > 40) S.trades.pop()
|
||||||
|
S.realized += pnl - fee
|
||||||
|
S.fee += fee
|
||||||
|
if (pnl >= 0) {
|
||||||
|
S.wins++
|
||||||
|
S.grossWin += pnl
|
||||||
|
} else {
|
||||||
|
S.losses++
|
||||||
|
S.grossLoss += -pnl
|
||||||
|
}
|
||||||
|
// reopen a fresh LONG US-equity position (avoid duplicates of current book)
|
||||||
|
const held = new Set(S.positions.map((p) => p.symbol))
|
||||||
|
const candidates = LONG_POOL.filter((s) => !held.has(s))
|
||||||
|
S.positions[bestIdx] = newPosition(candidates.length ? pick(candidates) : closed.symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// new orchestration cycle every ~24 frames
|
||||||
|
if (S.frame % 24 === 0) {
|
||||||
|
S.cycle++
|
||||||
|
S.decisionTs = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// jiggle flow + signal noise so those panels stay alive (short book stays
|
||||||
|
// bearish, longs stay bullish — keeps signal/topology directions consistent)
|
||||||
|
for (const s of DEMO_UNIVERSE) {
|
||||||
|
S.flowNet[s] = Math.max(20_000, S.flowNet[s] * rnd(0.97, 1.035))
|
||||||
|
const next = S.signalScore[s] + rnd(-0.08, 0.09)
|
||||||
|
S.signalScore[s] = SHORT_SET.has(s)
|
||||||
|
? Math.max(-2, Math.min(-0.1, next))
|
||||||
|
: Math.max(0.1, Math.min(2.4, next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(S: SimState): DemoDataset {
|
||||||
|
const liveUpnl = S.positions.reduce((s, p) => s + upnl(p), 0)
|
||||||
|
const equity = INITIAL + S.realized + liveUpnl
|
||||||
|
const pnl = equity - INITIAL
|
||||||
|
const marginUsed = S.positions.reduce((s, p) => s + (p.qty * p.entry) / p.lev, 0)
|
||||||
|
const total = S.wins + S.losses
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
total_equity: equity,
|
||||||
|
wallet_balance: equity - liveUpnl,
|
||||||
|
unrealized_profit: liveUpnl,
|
||||||
|
total_unrealized_profit: liveUpnl, // RiskRadar reads this extra field
|
||||||
|
available_balance: Math.max(0, equity - marginUsed),
|
||||||
|
total_pnl: pnl,
|
||||||
|
total_pnl_pct: (pnl / INITIAL) * 100,
|
||||||
|
initial_balance: INITIAL,
|
||||||
|
daily_pnl: S.realized + liveUpnl,
|
||||||
|
position_count: S.positions.length,
|
||||||
|
margin_used: marginUsed,
|
||||||
|
margin_used_pct: Math.min(82, (marginUsed / equity) * 100 * 1.6),
|
||||||
|
} as unknown as AccountInfo
|
||||||
|
|
||||||
|
const positions: Position[] = S.positions.map((p) => {
|
||||||
|
const u = upnl(p)
|
||||||
|
const notional = p.qty * p.entry
|
||||||
|
const margin = notional / p.lev
|
||||||
|
const liq = p.side === 'long' ? p.entry * (1 - 0.9 / p.lev) : p.entry * (1 + 0.9 / p.lev)
|
||||||
|
return {
|
||||||
|
symbol: p.symbol,
|
||||||
|
side: p.side,
|
||||||
|
entry_price: p.entry,
|
||||||
|
mark_price: p.mark,
|
||||||
|
quantity: p.qty,
|
||||||
|
leverage: p.lev,
|
||||||
|
unrealized_pnl: u,
|
||||||
|
unrealized_pnl_pct: (u / margin) * 100,
|
||||||
|
liquidation_price: liq,
|
||||||
|
margin_used: margin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const winRate = total > 0 ? (S.wins / total) * 100 : 0
|
||||||
|
const avgWin = S.wins > 0 ? S.grossWin / S.wins : 0
|
||||||
|
const avgLoss = S.losses > 0 ? S.grossLoss / S.losses : 0
|
||||||
|
const fullStats: TraderFullStats = {
|
||||||
|
total_trades: total,
|
||||||
|
win_trades: S.wins,
|
||||||
|
loss_trades: S.losses,
|
||||||
|
win_rate: winRate,
|
||||||
|
profit_factor: S.grossLoss > 0 ? S.grossWin / S.grossLoss : S.grossWin,
|
||||||
|
sharpe_ratio: 2.05 + Math.sin(S.frame / 90) * 0.12,
|
||||||
|
total_pnl: pnl,
|
||||||
|
total_fee: S.fee,
|
||||||
|
avg_win: avgWin,
|
||||||
|
avg_loss: avgLoss,
|
||||||
|
max_drawdown_pct: 0.052,
|
||||||
|
}
|
||||||
|
|
||||||
|
// recent closed trades
|
||||||
|
const histPositions = S.trades.map(
|
||||||
|
(t) =>
|
||||||
|
({
|
||||||
|
id: t.id,
|
||||||
|
symbol: t.symbol,
|
||||||
|
side: t.side,
|
||||||
|
quantity: t.qty,
|
||||||
|
entry_price: t.entry,
|
||||||
|
exit_price: t.exit,
|
||||||
|
exit_time: new Date(S.decisionTs - (S.nextId - t.id) * 47_000).toISOString(),
|
||||||
|
realized_pnl: t.pnl,
|
||||||
|
fee: t.fee,
|
||||||
|
leverage: t.lev,
|
||||||
|
status: 'closed',
|
||||||
|
close_reason: t.pnl >= 0 ? 'take_profit' : 'stop_loss',
|
||||||
|
}) as unknown as HistoricalPosition,
|
||||||
|
)
|
||||||
|
|
||||||
|
// per-symbol aggregates
|
||||||
|
const bySym = new Map<string, { n: number; w: number; pnl: number }>()
|
||||||
|
for (const t of S.trades) {
|
||||||
|
const e = bySym.get(t.symbol) || { n: 0, w: 0, pnl: 0 }
|
||||||
|
e.n++
|
||||||
|
if (t.pnl >= 0) e.w++
|
||||||
|
e.pnl += t.pnl
|
||||||
|
bySym.set(t.symbol, e)
|
||||||
|
}
|
||||||
|
const symbolStats: SymbolStats[] = [...bySym.entries()]
|
||||||
|
.map(([symbol, e]) => ({
|
||||||
|
symbol,
|
||||||
|
total_trades: e.n,
|
||||||
|
win_trades: e.w,
|
||||||
|
win_rate: e.n > 0 ? (e.w / e.n) * 100 : 0,
|
||||||
|
total_pnl: e.pnl,
|
||||||
|
avg_pnl: e.n > 0 ? e.pnl / e.n : 0,
|
||||||
|
avg_hold_mins: Math.round(rnd(6, 38)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total_trades - a.total_trades)
|
||||||
|
|
||||||
|
const history = {
|
||||||
|
positions: histPositions,
|
||||||
|
stats: null,
|
||||||
|
symbol_stats: symbolStats,
|
||||||
|
direction_stats: [],
|
||||||
|
} as unknown as PositionHistoryResponse
|
||||||
|
|
||||||
|
// flow markets — long names show net BUYING (inflow), the short book shows net
|
||||||
|
// SELLING (outflow) so the topology's FLOW layer feeds the short row too.
|
||||||
|
const mkItem = (s: string, net: number): FlowMarketItem => {
|
||||||
|
const buyShare = net >= 0 ? 0.56 + Math.min(0.3, net / 6_000_000) : 0.4
|
||||||
|
const gross = Math.abs(net)
|
||||||
|
return {
|
||||||
|
key: `xyz:${s}`,
|
||||||
|
marketType: 'hip3_perp',
|
||||||
|
symbol: s,
|
||||||
|
netFlow: String(Math.round(net)),
|
||||||
|
buyNotional: String(Math.round(gross * buyShare)),
|
||||||
|
sellNotional: String(Math.round(gross * (1 - buyShare))),
|
||||||
|
trades: Math.round(rnd(150, 9000)),
|
||||||
|
latestPrice: String(demoSeedPrice(s)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inflow: FlowMarketItem[] = LONG_POOL.slice()
|
||||||
|
.sort((a, b) => S.flowNet[b] - S.flowNet[a])
|
||||||
|
.map((s) => mkItem(s, S.flowNet[s]))
|
||||||
|
const outflow: FlowMarketItem[] = [...SHORT_SET].map((s) => mkItem(s, -Math.abs(S.flowNet[s]) * 0.6))
|
||||||
|
const flow: FlowMarketsResponse = { data: { by: 'netFlow', window: '1h', inflow, outflow } }
|
||||||
|
|
||||||
|
// signal ranking — mostly bullish US equities
|
||||||
|
const ranked = [...DEMO_UNIVERSE].sort((a, b) => S.signalScore[b] - S.signalScore[a])
|
||||||
|
const items: SignalRankItem[] = ranked.map((s, i) => {
|
||||||
|
const score = S.signalScore[s]
|
||||||
|
return {
|
||||||
|
rank: i + 1,
|
||||||
|
symbol: s,
|
||||||
|
market_type: 'hip3_perp',
|
||||||
|
bias: SHORT_SET.has(s) ? 'bearish' : 'bullish',
|
||||||
|
score: Math.round(score * 100) / 100,
|
||||||
|
category: 'us_equity',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const signalRank: SignalRankingResponse = { items }
|
||||||
|
|
||||||
|
// decision candidates — top longs plus the short book, so the DECISION layer
|
||||||
|
// (and execution log) carries shorts through to EXECUTE/HOLD.
|
||||||
|
const candidates = [...new Set([...ranked.slice(0, 8), ...SHORT_SET])]
|
||||||
|
const decisions: DecisionRecord[] = Array.from({ length: 4 }).map((_, k) => {
|
||||||
|
const cyc = S.cycle - k
|
||||||
|
const acts = S.positions.slice(0, 6).map((p) => ({
|
||||||
|
action: 'hold',
|
||||||
|
symbol: p.symbol,
|
||||||
|
quantity: p.qty,
|
||||||
|
leverage: p.lev,
|
||||||
|
price: p.mark,
|
||||||
|
confidence: Math.round(rnd(62, 88)),
|
||||||
|
reasoning: 'Signal Lab confirms trend; cost/liq structure supports the level.',
|
||||||
|
timestamp: new Date(S.decisionTs - k * 300_000).toISOString(),
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
timestamp: new Date(S.decisionTs - k * 300_000).toISOString(),
|
||||||
|
cycle_number: cyc,
|
||||||
|
system_prompt: '',
|
||||||
|
input_prompt: '',
|
||||||
|
cot_trace:
|
||||||
|
'US-equity tape is broadly bid: SP500 and semis (NVDA, MU, TSM) lead net inflow with bullish Signal Lab bias. Holding winners ≥ entry, trimming only on structure breaks.',
|
||||||
|
decision_json: '',
|
||||||
|
account_state: {} as never,
|
||||||
|
positions: [],
|
||||||
|
candidate_coins: candidates,
|
||||||
|
decisions: acts as never,
|
||||||
|
execution_log: S.positions
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((p) => `${p.symbol} hold succeeded`),
|
||||||
|
success: true,
|
||||||
|
} as unknown as DecisionRecord
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
is_running: true,
|
||||||
|
call_count: S.cycle,
|
||||||
|
scan_interval: '5m',
|
||||||
|
ai_model: 'claw402',
|
||||||
|
strategy_type: 'ai_trading',
|
||||||
|
} as unknown as SystemStatus
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
account,
|
||||||
|
positions,
|
||||||
|
decisions,
|
||||||
|
fullStats,
|
||||||
|
history,
|
||||||
|
config: {
|
||||||
|
scan_interval_minutes: 5,
|
||||||
|
ai_model: 'claw402',
|
||||||
|
strategy_name: 'NOFX Claw402 Auto Strategy',
|
||||||
|
btc_eth_leverage: 10,
|
||||||
|
altcoin_leverage: 10,
|
||||||
|
},
|
||||||
|
flow,
|
||||||
|
signalRank,
|
||||||
|
activeSymbol: DEMO_ACTIVE_SYMBOL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoEngine(active: boolean): DemoDataset | null {
|
||||||
|
const ref = useRef<SimState | null>(null)
|
||||||
|
const [, tick] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
ref.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ref.current = initState()
|
||||||
|
tick((n) => n + 1)
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
step(ref.current)
|
||||||
|
tick((n) => n + 1)
|
||||||
|
}
|
||||||
|
}, TICK_MS)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
if (!active || !ref.current) return null
|
||||||
|
return build(ref.current)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user