mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 14:00:57 +08:00
feat: data-driven autopilot profitability tuning + edge profile panel
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).
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <reasoning> and <decision> 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 <reasoning> and <decision> 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("<reasoning>\n")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
138
web/src/components/terminal/EdgeProfile.tsx
Normal file
138
web/src/components/terminal/EdgeProfile.tsx
Normal file
@@ -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 <div className="tm-sc">No closed trades yet.</div>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={bucket.label} style={{ marginBottom: 7 }}>
|
||||
<div className="tm-mono" style={{ display: 'flex', alignItems: 'baseline', fontSize: 11, marginBottom: 2 }}>
|
||||
<span style={{ fontWeight: 500, minWidth: 52 }}>{bucket.label}</span>
|
||||
<span className="tm-sc">
|
||||
{bucket.n} trades · {bucket.n > 0 ? `${winPct.toFixed(0)}% win` : '—'} · fees ${bucket.fees.toFixed(2)}
|
||||
</span>
|
||||
<span className={up ? 'tm-up' : 'tm-dn'} style={{ marginLeft: 'auto', fontWeight: 600 }}>
|
||||
{bucket.n > 0 ? fmtUsd(bucket.net) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
{/* diverging net bar around a center axis */}
|
||||
<div style={{ display: 'flex', height: 4, background: 'var(--tm-hair)' }}>
|
||||
<div style={{ width: '50%', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
{!up && (
|
||||
<div style={{ height: 4, width: `${(Math.abs(bucket.net) / maxAbsNet) * 100}%`, background: 'var(--tm-dn)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: '50%' }}>
|
||||
{up && bucket.net > 0 && (
|
||||
<div style={{ height: 4, width: `${(bucket.net / maxAbsNet) * 100}%`, background: 'var(--tm-up)' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
{holdBuckets.map(row)}
|
||||
<div style={{ borderTop: '1px solid var(--tm-hair)', margin: '8px 0 7px' }} />
|
||||
{sideBuckets.map(row)}
|
||||
<div className="tm-sc" style={{ marginTop: 6, fontSize: 9 }}>
|
||||
last {sample} closed · {takeaway}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeProfile
|
||||
@@ -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({
|
||||
</div>
|
||||
<div className="tm-rule" />
|
||||
|
||||
{/* market net inflow (Vergex) · by-symbol history — balanced two-column footer */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.5fr) minmax(0,1fr)' }}>
|
||||
{/* market net inflow (Vergex) · by-symbol history · edge profile — footer */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.2fr) minmax(0,0.9fr) minmax(0,0.9fr)' }}>
|
||||
<div style={{ ...sc, borderRight: cellBorder }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8 }}>
|
||||
<span className="tm-px" style={{ fontSize: 12 }}>Market net inflow</span>
|
||||
@@ -550,7 +551,7 @@ export function TerminalDashboard({
|
||||
</div>
|
||||
<FlowMarkets items={flowItems} window={flow?.data?.window} />
|
||||
</div>
|
||||
<div style={sc}>
|
||||
<div style={{ ...sc, borderRight: cellBorder }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
|
||||
<span className="tm-px" style={{ fontSize: 11 }}>By symbol</span>
|
||||
<span className="tm-sc">By-symbol history · trades/win/pnl</span>
|
||||
@@ -568,6 +569,13 @@ export function TerminalDashboard({
|
||||
</div>
|
||||
)) : <div className="tm-sc">No symbol history.</div>}
|
||||
</div>
|
||||
<div style={sc}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 8 }}>
|
||||
<span className="tm-px" style={{ fontSize: 11 }}>Edge profile</span>
|
||||
<span className="tm-sc">Net by hold time & side · after fees</span>
|
||||
</div>
|
||||
<EdgeProfile positions={history?.positions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user