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:
tinkle-community
2026-07-07 19:21:49 +09:00
parent e593874912
commit c53a563bff
9 changed files with 261 additions and 42 deletions

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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")