Files
nofx/trader/auto_trader_throttle.go
tinkle-community c53a563bff 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).
2026-07-07 19:21:49 +09:00

287 lines
8.0 KiB
Go

package trader
import (
"fmt"
"nofx/kernel"
"nofx/market"
"nofx/store"
"strings"
"time"
)
const (
// 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
// MaxPositions (concurrent) + the 45m min-hold + the 90m per-symbol reentry
// cooldown, so the per-hour cap only needs to be high enough not to block the
// directional pair from re-establishing after positions close. A tight value
// here (e.g. 2) starves the strategy: once a couple opens fire, every later
// cycle is blocked and the book drains to flat. Keep it generous.
autopilotMaxOpensPerHour = 30
autopilotMaxOpensPerCycle = 6
earlyCloseStopLossBypassPct = -2.5
earlyCloseTakeProfitBypassPct = 5.0
noiseCloseLossFloorPct = -1.0
noiseCloseProfitCeilingPct = 2.0
)
func isOpenAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "open_long", "open_short":
return true
default:
return false
}
}
func isCloseAction(action string) bool {
switch strings.ToLower(strings.TrimSpace(action)) {
case "close_long", "close_short":
return true
default:
return false
}
}
func closeActionSide(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "close_long":
return "long"
case "close_short":
return "short"
default:
return ""
}
}
func openActionSide(action string) string {
switch strings.ToLower(strings.TrimSpace(action)) {
case "open_long":
return "long"
case "open_short":
return "short"
default:
return ""
}
}
func normalizedDecisionSymbol(symbol string) string {
return market.Normalize(strings.TrimSpace(symbol))
}
func (at *AutoTrader) tradeThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string {
if ctx == nil {
return ""
}
switch {
case isOpenAction(decision.Action):
return at.openThrottleReason(decision, ctx, opensQueuedThisCycle)
case isCloseAction(decision.Action):
return at.closeThrottleReason(decision, ctx)
default:
return ""
}
}
func (at *AutoTrader) openThrottleReason(decision kernel.Decision, ctx *kernel.Context, opensQueuedThisCycle int) string {
symbol := normalizedDecisionSymbol(decision.Symbol)
if symbol == "" {
return ""
}
if opensQueuedThisCycle >= autopilotMaxOpensPerCycle {
return fmt.Sprintf("trade throttle: only %d new position may be opened per cycle", autopilotMaxOpensPerCycle)
}
if pos := findAnyContextPosition(ctx, symbol); pos != nil {
return fmt.Sprintf("trade throttle: %s already has an open %s position; manage or close it before opening another side", symbol, pos.Side)
}
openCount, err := at.countRecentOpenOrders(time.Now().Add(-1 * time.Hour))
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err)
} else if openCount >= autopilotMaxOpensPerHour {
return fmt.Sprintf("trade throttle: %d open order already executed in the last hour; max is %d", openCount, autopilotMaxOpensPerHour)
}
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-autopilotReentryCooldown)); order != nil {
age := time.Since(time.UnixMilli(order.CreatedAt))
remaining := autopilotReentryCooldown - age
if remaining < 0 {
remaining = 0
}
return fmt.Sprintf("trade throttle: %s was closed %s ago; wait %s before re-entry", symbol, roundDuration(age), roundDuration(remaining))
}
return ""
}
func (at *AutoTrader) closeThrottleReason(decision kernel.Decision, ctx *kernel.Context) string {
symbol := normalizedDecisionSymbol(decision.Symbol)
side := closeActionSide(decision.Action)
if symbol == "" || side == "" {
return ""
}
pos := findContextPosition(ctx, symbol, side)
pnlPct := 0.0
entryTime := int64(0)
if pos != nil {
pnlPct = pos.UnrealizedPnLPct
entryTime = pos.UpdateTime
}
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-autopilotNoiseCloseHoldDuration)); order != nil && order.CreatedAt > entryTime {
entryTime = order.CreatedAt
}
if entryTime <= 0 {
return ""
}
heldFor := time.Since(time.UnixMilli(entryTime))
if heldFor < 0 {
heldFor = 0
}
if heldFor >= autopilotMinHoldDuration {
if heldFor >= autopilotNoiseCloseHoldDuration ||
pnlPct <= noiseCloseLossFloorPct ||
pnlPct >= noiseCloseProfitCeilingPct {
return ""
}
remaining := autopilotNoiseCloseHoldDuration - heldFor
return fmt.Sprintf(
"trade throttle: %s %s has been held for %s with PnL %.2f%%; it is still inside the noise band %.1f%% to %.1f%%, so wait about %s before a flat/small close",
symbol,
side,
roundDuration(heldFor),
pnlPct,
noiseCloseLossFloorPct,
noiseCloseProfitCeilingPct,
roundDuration(remaining),
)
}
// Do not block true risk exits or unusually strong take-profit exits.
if pnlPct <= earlyCloseStopLossBypassPct || pnlPct >= earlyCloseTakeProfitBypassPct {
return ""
}
remaining := autopilotMinHoldDuration - heldFor
return fmt.Sprintf(
"trade throttle: %s %s has only been held for %s with PnL %.2f%%; min AI-managed hold is %s unless loss <= %.1f%% or profit >= %.1f%%",
symbol,
side,
roundDuration(heldFor),
pnlPct,
roundDuration(autopilotMinHoldDuration),
earlyCloseStopLossBypassPct,
earlyCloseTakeProfitBypassPct,
) + fmt.Sprintf("; wait about %s", roundDuration(remaining))
}
func findContextPosition(ctx *kernel.Context, symbol string, side string) *kernel.PositionInfo {
if ctx == nil {
return nil
}
for i := range ctx.Positions {
pos := &ctx.Positions[i]
if normalizedDecisionSymbol(pos.Symbol) == symbol && strings.EqualFold(pos.Side, side) {
return pos
}
}
return nil
}
func findAnyContextPosition(ctx *kernel.Context, symbol string) *kernel.PositionInfo {
if ctx == nil {
return nil
}
for i := range ctx.Positions {
pos := &ctx.Positions[i]
if normalizedDecisionSymbol(pos.Symbol) == symbol {
return pos
}
}
return nil
}
func (at *AutoTrader) recentOrders(limit int) ([]*store.TraderOrder, error) {
if at == nil || at.store == nil {
return nil, nil
}
return at.store.Order().GetTraderOrders(at.id, limit)
}
func (at *AutoTrader) countRecentOpenOrders(since time.Time) (int, error) {
orders, err := at.recentOrders(100)
if err != nil {
return 0, err
}
sinceMs := since.UTC().UnixMilli()
count := 0
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if isOpenAction(order.OrderAction) {
count++
}
}
return count, nil
}
func (at *AutoTrader) findRecentCloseOrder(symbol string, since time.Time) *store.TraderOrder {
orders, err := at.recentOrders(100)
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent close orders: %v", err)
return nil
}
sinceMs := since.UTC().UnixMilli()
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if normalizedDecisionSymbol(order.Symbol) == symbol && isCloseAction(order.OrderAction) {
return order
}
}
return nil
}
func (at *AutoTrader) findRecentOpenOrder(symbol string, side string, since time.Time) *store.TraderOrder {
orders, err := at.recentOrders(100)
if err != nil {
at.logWarnf("⚠️ Trade throttle could not read recent open orders: %v", err)
return nil
}
sinceMs := since.UTC().UnixMilli()
for _, order := range orders {
if order == nil || order.CreatedAt < sinceMs || isCanceledOrder(order) {
continue
}
if normalizedDecisionSymbol(order.Symbol) == symbol &&
strings.EqualFold(openActionSide(order.OrderAction), side) {
return order
}
}
return nil
}
func isCanceledOrder(order *store.TraderOrder) bool {
status := strings.ToUpper(strings.TrimSpace(order.Status))
return status == "CANCELED" || status == "CANCELLED" || status == "REJECTED" || status == "EXPIRED"
}
func roundDuration(d time.Duration) string {
if d < time.Minute {
return "0m"
}
return d.Round(time.Minute).String()
}