From c53a563bff111a089ad4d75aa778c18fdb3a8d5b Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Tue, 7 Jul 2026 19:21:49 +0900 Subject: [PATCH] feat: data-driven autopilot profitability tuning + edge profile panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analysis of 410 live closed trades found the edge is real but was being destroyed by execution: gross +$267 vs $245 fees; trades held <1h were net negative (the <15m bucket alone: -$48 on $66 fees) while 1h+ holds carried +$78; shorts lost $72 while longs made $94 — and both the prompt and the engine were manufacturing those losers. Changes, each tied to the data: - Prompt: removed the 'MUST open at least one long AND one short every cycle' mandate (forced weak-signal shorts); direction is now data-driven with 'never open just to balance the book'. Added fee-awareness (round trip ≈ 0.1% notional, require expected move ≥ 3x cost) and aligned the hold discipline with the backend throttle. - Forced book-balance opens now require |board z-score| ≥ 0.75 — the engine previously force-opened full-size 10x positions on near-neutral signals with hardcoded confidence 70. DirectionalCandidates now carries scores. - Min AI-managed hold raised 45m → 60m (the 15-60m bucket still bled after the earlier throttle landed). - Legacy prompt hygiene: vergex path drops long-only-era custom prompts and zh-era configs fall back wholesale to built-in English sections — fixes the two long-failing kernel prompt tests. - New Edge Profile dashboard panel: net after fees by hold-time bucket and side, computed from recent closed trades, with an automatic takeaway line — the fee/churn regression detector, always visible. Fixed the HistoricalPosition timestamp types (epoch ms, not strings). --- kernel/engine.go | 29 ++-- kernel/engine_directional_test.go | 20 ++- kernel/engine_prompt.go | 49 ++++++- trader/auto_trader_force.go | 37 +++-- trader/auto_trader_throttle.go | 4 +- web/src/components/terminal/EdgeProfile.tsx | 138 ++++++++++++++++++ .../components/terminal/TerminalDashboard.tsx | 14 +- web/src/components/trader/PositionHistory.tsx | 6 +- web/src/types/trading.ts | 6 +- 9 files changed, 261 insertions(+), 42 deletions(-) create mode 100644 web/src/components/terminal/EdgeProfile.tsx diff --git a/kernel/engine.go b/kernel/engine.go index c538a566..3d454623 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -877,16 +877,24 @@ func minInt(a, b int) int { return b } -// DirectionalCandidates returns bullish (long) and bearish (short) candidate -// symbols from the most recent Vergex signal ranking, each ordered by upstream -// rank (strongest first). Only populated for vergex_signal coin sources, since -// that is the only source carrying a per-symbol directional bias. -func (e *StrategyEngine) DirectionalCandidates() (bullish []string, bearish []string) { +// DirectionalCandidate is a Vergex board candidate with its directional +// signal strength (the board z-score; sign follows the bias direction). +type DirectionalCandidate struct { + Symbol string + Score float64 +} + +// DirectionalCandidates returns bullish (long) and bearish (short) candidates +// from the most recent Vergex signal ranking, each ordered by upstream rank +// (strongest first) and carrying the signal score so callers can require a +// minimum strength. Only populated for vergex_signal coin sources, since that +// is the only source carrying a per-symbol directional bias. +func (e *StrategyEngine) DirectionalCandidates() (bullish []DirectionalCandidate, bearish []DirectionalCandidate) { if e == nil || len(e.vergexRankingCache) == 0 { return nil, nil } type ranked struct { - sym string + cand DirectionalCandidate rank int } rankKey := func(r int) int { @@ -900,20 +908,21 @@ func (e *StrategyEngine) DirectionalCandidates() (bullish []string, bearish []st if item == nil { continue } + entry := ranked{DirectionalCandidate{Symbol: sym, Score: item.Score}, item.Rank} switch strings.ToLower(strings.TrimSpace(item.Bias)) { case "bearish", "short", "sell": - br = append(br, ranked{sym, item.Rank}) + br = append(br, entry) case "bullish", "long", "buy": - bl = append(bl, ranked{sym, item.Rank}) + bl = append(bl, entry) } } sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) }) sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) }) for _, r := range bl { - bullish = append(bullish, r.sym) + bullish = append(bullish, r.cand) } for _, r := range br { - bearish = append(bearish, r.sym) + bearish = append(bearish, r.cand) } return bullish, bearish } diff --git a/kernel/engine_directional_test.go b/kernel/engine_directional_test.go index 9aa4ccab..eb89fef1 100644 --- a/kernel/engine_directional_test.go +++ b/kernel/engine_directional_test.go @@ -9,22 +9,28 @@ import ( func TestDirectionalCandidates(t *testing.T) { e := &StrategyEngine{ vergexRankingCache: map[string]*vergex.SignalRankItem{ - "xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2}, - "xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1}, - "BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3}, - "ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1}, - "SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1}, + "xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2, Score: 1.07}, + "xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1, Score: 1.76}, + "BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3, Score: -0.9}, + "ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1, Score: -0.05}, + "SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1, Score: 0.4}, }, } bull, bear := e.DirectionalCandidates() - if len(bull) != 2 || bull[0] != "xyz:AAPL" || bull[1] != "xyz:NVDA" { + if len(bull) != 2 || bull[0].Symbol != "xyz:AAPL" || bull[1].Symbol != "xyz:NVDA" { t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull) } - if len(bear) != 2 || bear[0] != "ETH" || bear[1] != "BTC" { + if bull[0].Score != 1.76 || bull[1].Score != 1.07 { + t.Fatalf("bullish candidates should carry their board scores, got %v", bull) + } + if len(bear) != 2 || bear[0].Symbol != "ETH" || bear[1].Symbol != "BTC" { t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear) } + if bear[0].Score != -0.05 || bear[1].Score != -0.9 { + t.Fatalf("bearish candidates should carry their board scores, got %v", bear) + } } func TestDirectionalCandidatesEmpty(t *testing.T) { diff --git a/kernel/engine_prompt.go b/kernel/engine_prompt.go index 6c0eb35c..e9e6d73d 100644 --- a/kernel/engine_prompt.go +++ b/kernel/engine_prompt.go @@ -26,6 +26,14 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string zh := false singleSymbol, primarySymbol := e.singleSymbolInfo() + // Configs created in the Chinese-UI era carry legacy stored prompt sections + // and custom prompts written for a different contract; ignore them wholesale + // and fall back to the canonical built-in English sections. + legacyZhConfig := strings.EqualFold(strings.TrimSpace(e.config.Language), "zh") + if legacyZhConfig { + promptSections = store.PromptSectionsConfig{} + } + if e.usesVergexSignalPrompt() { return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol) } @@ -147,6 +155,9 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string // 2. It guarantees a stock-specific, US-equity-tuned briefing // regardless of when the strategy was first created. customPrompt := englishOnlyPromptSection(e.config.CustomPrompt) + if legacyZhConfig { + customPrompt = "" + } if singleSymbol && market.IsXyzDexAsset(primarySymbol) { customPrompt = buildXYZStockCustomPrompt(primarySymbol) } @@ -204,7 +215,8 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n") sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n") sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n") - sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n") + sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 60 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n") + sb.WriteString("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\n") sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n") } else { sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n") @@ -220,7 +232,8 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n") sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n") sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n") - sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n") + sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 60 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n") + sb.WriteString("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\n") sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n") } @@ -233,7 +246,7 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh) writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh) - customPrompt := englishOnlyPromptSection(e.config.CustomPrompt) + customPrompt := vergexCustomPromptSection(e.config.CustomPrompt) if customPrompt != "" { sb.WriteString("# User Preference\n\n") sb.WriteString(customPrompt) @@ -243,6 +256,32 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant return sb.String() } +// vergexCustomPromptSection returns the user's custom prompt for the vergex +// path, dropping legacy directional overrides ("long only" era) that would +// contradict the data-driven direction rule baked into this prompt. +func vergexCustomPromptSection(section string) string { + trimmed := englishOnlyPromptSection(section) + if trimmed == "" { + return "" + } + lower := strings.ToLower(trimmed) + legacyDirectives := []string{ + "long only", + "long-only", + "do not short", + "no shorts", + "must open a long", + "short only", + "short-only", + } + for _, directive := range legacyDirectives { + if strings.Contains(lower, directive) { + return "" + } + } + return trimmed +} + func englishOnlyPromptSection(section string) string { trimmed := strings.TrimSpace(section) if trimmed == "" { @@ -335,13 +374,13 @@ func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskCon sb.WriteString("Use XML tags and to separate concise analysis from the decision JSON.\n\n") sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n") if !singleSymbol { - sb.WriteString("This cycle you MUST include at least one `open_long` (pick the strongest net-inflow / bullish name) AND at least one `open_short` (pick the strongest net-outflow / bearish name); omit a side only if no suitable name exists for it.\n\n") + sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n") } } else { sb.WriteString("Use XML tags and to separate concise analysis from the decision JSON.\n\n") sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n") if !singleSymbol { - sb.WriteString("This cycle you MUST include at least one `open_long` (pick the strongest net-inflow / bullish name) AND at least one `open_short` (pick the strongest net-outflow / bearish name); omit a side only if no suitable name exists for it.\n\n") + sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n") } } sb.WriteString("\n") diff --git a/trader/auto_trader_force.go b/trader/auto_trader_force.go index dedcc88f..0f7b7a5c 100644 --- a/trader/auto_trader_force.go +++ b/trader/auto_trader_force.go @@ -1,23 +1,31 @@ package trader import ( + "math" "strings" "nofx/kernel" ) -// ensureLongShortCoverage keeps a balanced book each cycle: it fills toward -// roughly half the MaxPositions slots long and half short. The AI still drives -// selection/sizing whenever it acts; this is a deterministic top-up — if the -// AI's decisions plus existing positions fall short of the per-direction target, -// the engine force-opens the strongest unused bullish/bearish candidates to -// reach it (never exceeding MaxPositions). +// forcedCoverageMinScore is the minimum absolute board z-score a candidate +// needs before the engine will force-open it for book balance. Live trade +// history showed forced entries on near-neutral signals (|z| < 0.3) were a +// systematic money loser — especially shorts — while trades on strong signals +// carried the edge. Below this bar the book is simply left unbalanced. +const forcedCoverageMinScore = 0.75 + +// ensureLongShortCoverage tops the book up toward roughly half the +// MaxPositions slots long and half short — but only with candidates whose +// directional signal is actually strong (see forcedCoverageMinScore). The AI +// still drives selection/sizing whenever it acts; this is a deterministic +// top-up, and an unbalanced book is preferred over a forced weak trade. // // Forced opens are sized from account equity via applyAutopilotFullSizeOpen and // run through the same code-enforced risk checks (position-value ratio, minimum // size, margin) as any other open. Guards: // - skipped entirely in safe mode (AI unhealthy), // - scoped to the vergex_signal source (the only one with directional bias), +// - requires |signal score| >= forcedCoverageMinScore, // - never exceeds MaxPositions, // - never doubles a base symbol already held or already in the decision set. func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx *kernel.Context, equity float64) []kernel.Decision { @@ -65,8 +73,9 @@ func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx * bullish, bearish := at.strategyEngine.DirectionalCandidates() // fill a direction up to its target, drawing from the strongest unused - // candidates, never exceeding MaxPositions. - fill := func(action string, cands []string, have, target int) { + // candidates that clear the signal-strength floor, never exceeding + // MaxPositions. + fill := func(action string, cands []kernel.DirectionalCandidate, have, target int) { for _, c := range cands { if have >= target { return @@ -74,13 +83,19 @@ func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx * if maxPos > 0 && posCount >= maxPos { return } - b := universeBaseKey(c) + if math.Abs(c.Score) < forcedCoverageMinScore { + // candidates are rank-ordered; weaker ones may still follow, + // so keep scanning instead of breaking + at.logInfof("⚖️ Skipped forced %s %s: signal score %.2f below %.2f floor", action, c.Symbol, c.Score, forcedCoverageMinScore) + continue + } + b := universeBaseKey(c.Symbol) if b == "" || held[b] { continue } d := kernel.Decision{ Action: action, - Symbol: c, + Symbol: c.Symbol, Confidence: 70, Reasoning: "Forced " + action + " to fill the balanced long/short book (autopilot)", } @@ -89,7 +104,7 @@ func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx * held[b] = true have++ posCount++ - at.logInfof("⚖️ Forced %s %s (account-sized %.2f USDT, %dx)", action, c, d.PositionSizeUSD, d.Leverage) + at.logInfof("⚖️ Forced %s %s (score %.2f, account-sized %.2f USDT, %dx)", action, c.Symbol, c.Score, d.PositionSizeUSD, d.Leverage) } } diff --git a/trader/auto_trader_throttle.go b/trader/auto_trader_throttle.go index b9db3c8f..53a40f04 100644 --- a/trader/auto_trader_throttle.go +++ b/trader/auto_trader_throttle.go @@ -10,7 +10,9 @@ import ( ) const ( - autopilotMinHoldDuration = 45 * time.Minute + // Live history: trades held under an hour were net-negative after fees + // (the 15-60m bucket bled), while the edge concentrated in 1h+ holds. + autopilotMinHoldDuration = 60 * time.Minute autopilotNoiseCloseHoldDuration = 90 * time.Minute autopilotReentryCooldown = 30 * time.Minute // Allow one long + one short per cycle. The real exposure/churn limits are diff --git a/web/src/components/terminal/EdgeProfile.tsx b/web/src/components/terminal/EdgeProfile.tsx new file mode 100644 index 00000000..7ae7a663 --- /dev/null +++ b/web/src/components/terminal/EdgeProfile.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react' +import type { HistoricalPosition } from '../../types' + +/** + * EdgeProfile — where the money actually comes from. Aggregates recent closed + * trades into hold-duration buckets plus a long/short split, each with net + * PnL, fee load and win rate. This is the panel that made the fee-drag and + * churn problems visible, kept on the dashboard so regressions show up + * immediately. + */ + +interface EdgeProfileProps { + positions?: HistoricalPosition[] +} + +interface BucketAgg { + label: string + n: number + net: number + fees: number + wins: number +} + +function newBucket(label: string): BucketAgg { + return { label, n: 0, net: 0, fees: 0, wins: 0 } +} + +function add(bucket: BucketAgg, pos: HistoricalPosition) { + bucket.n += 1 + bucket.net += pos.realized_pnl || 0 + bucket.fees += pos.fee || 0 + if ((pos.realized_pnl || 0) > 0) bucket.wins += 1 +} + +function fmtUsd(n: number): string { + const sign = n < 0 ? '-' : '+' + return `${sign}$${Math.abs(n).toFixed(2)}` +} + +/** Accepts epoch ms (the API's format) or a date string, returns epoch ms. */ +function toEpochMs(value: number | string): number { + if (typeof value === 'number') return value + const numeric = Number(value) + if (Number.isFinite(numeric) && numeric > 0) return numeric + return Date.parse(value) +} + +export function EdgeProfile({ positions }: EdgeProfileProps) { + const { holdBuckets, sideBuckets, sample } = useMemo(() => { + const holds = [ + newBucket('<15m'), + newBucket('15-60m'), + newBucket('1-3h'), + newBucket('>3h'), + ] + const sides = [newBucket('long'), newBucket('short')] + let counted = 0 + + for (const pos of positions ?? []) { + if ((pos.status || '').toUpperCase() !== 'CLOSED') continue + const entry = toEpochMs(pos.entry_time) + const exit = toEpochMs(pos.exit_time) + if (!Number.isFinite(entry) || !Number.isFinite(exit) || exit <= entry) { + continue + } + counted += 1 + + const holdMin = (exit - entry) / 60000 + const holdBucket = + holdMin < 15 ? holds[0] : holdMin < 60 ? holds[1] : holdMin < 180 ? holds[2] : holds[3] + add(holdBucket, pos) + + const sideBucket = + (pos.side || '').toLowerCase() === 'short' ? sides[1] : sides[0] + add(sideBucket, pos) + } + + return { holdBuckets: holds, sideBuckets: sides, sample: counted } + }, [positions]) + + if (sample === 0) { + return
No closed trades yet.
+ } + + const maxAbsNet = Math.max(0.01, ...holdBuckets.map((b) => Math.abs(b.net))) + + const row = (bucket: BucketAgg) => { + const winPct = bucket.n > 0 ? (100 * bucket.wins) / bucket.n : 0 + const up = bucket.net >= 0 + return ( +
+
+ {bucket.label} + + {bucket.n} trades · {bucket.n > 0 ? `${winPct.toFixed(0)}% win` : '—'} · fees ${bucket.fees.toFixed(2)} + + + {bucket.n > 0 ? fmtUsd(bucket.net) : '—'} + +
+ {/* diverging net bar around a center axis */} +
+
+ {!up && ( +
+ )} +
+
+ {up && bucket.net > 0 && ( +
+ )} +
+
+
+ ) + } + + // one-line takeaway: does patience pay on this book? + const shortHolds = holdBuckets[0].net + holdBuckets[1].net + const longHolds = holdBuckets[2].net + holdBuckets[3].net + const takeaway = + longHolds > shortHolds + ? `edge concentrates in holds ≥ 1h (${fmtUsd(longHolds)} vs ${fmtUsd(shortHolds)} under 1h)` + : `short holds outperform on this sample (${fmtUsd(shortHolds)} vs ${fmtUsd(longHolds)} ≥ 1h)` + + return ( +
+ {holdBuckets.map(row)} +
+ {sideBuckets.map(row)} +
+ last {sample} closed · {takeaway} +
+
+ ) +} + +export default EdgeProfile diff --git a/web/src/components/terminal/TerminalDashboard.tsx b/web/src/components/terminal/TerminalDashboard.tsx index 22d7f7ad..a719bb97 100644 --- a/web/src/components/terminal/TerminalDashboard.tsx +++ b/web/src/components/terminal/TerminalDashboard.tsx @@ -17,6 +17,7 @@ import { KlineChart } from './KlineChart' import { ExecutionLog } from './ExecutionLog' import { SignalMatrix } from './SignalMatrix' import { RiskRadar } from './RiskRadar' +import { EdgeProfile } from './EdgeProfile' import { useDemoEngine } from '../../lib/demo/useDemoEngine' // crypto majors trade on the Hyperliquid main dex (no hip3 cost/liq heatmap); @@ -540,8 +541,8 @@ export function TerminalDashboard({
- {/* market net inflow (Vergex) · by-symbol history — balanced two-column footer */} -
+ {/* market net inflow (Vergex) · by-symbol history · edge profile — footer */} +
Market net inflow @@ -550,7 +551,7 @@ export function TerminalDashboard({
-
+
By symbol By-symbol history · trades/win/pnl @@ -568,6 +569,13 @@ export function TerminalDashboard({
)) :
No symbol history.
}
+
+
+ Edge profile + Net by hold time & side · after fees +
+ +
diff --git a/web/src/components/trader/PositionHistory.tsx b/web/src/components/trader/PositionHistory.tsx index 6fa5c473..e424ce2d 100644 --- a/web/src/components/trader/PositionHistory.tsx +++ b/web/src/components/trader/PositionHistory.tsx @@ -36,9 +36,9 @@ function formatDuration(minutes: number): string { } // Format date -function formatDate(dateStr: string): string { - if (!dateStr) return '-' - const date = new Date(dateStr) +function formatDate(value: number | string): string { + if (!value) return '-' + const date = new Date(value) if (isNaN(date.getTime())) return '-' return date.toLocaleDateString('zh-CN', { month: '2-digit', diff --git a/web/src/types/trading.ts b/web/src/types/trading.ts index bde02db5..5657c4d5 100644 --- a/web/src/types/trading.ts +++ b/web/src/types/trading.ts @@ -186,10 +186,12 @@ export interface HistoricalPosition { entry_quantity: number entry_price: number entry_order_id: string - entry_time: string + /** Epoch milliseconds. */ + entry_time: number exit_price: number exit_order_id: string - exit_time: string + /** Epoch milliseconds. */ + exit_time: number realized_pnl: number fee: number leverage: number