mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-27 22:12:52 +08:00
The anti-churn exit gates (min hold, noise-close window, re-entry cooldown, bypass and noise-band thresholds) were hardcoded constants in auto_trader_throttle.go — every flat-ish position was forced to hold 8h+ and changing the pacing meant a code change and redeploy. - RiskControlConfig gains 7 exit-gate fields (minutes / signed price-%), zero = built-in default; accessor methods centralize fallbacks and are hot-reloaded from the DB like the rest of the strategy config - Throttle reads the gates from the strategy; prompt hold/exit guidance is now rendered from the same values so the AI is told exactly what the code will enforce - Defaults softened: min hold 4h -> 1.5h, noise window 8h -> 3h, re-entry 3h -> 1.5h, bypasses -5/+12 -> -3/+8, noise band -4..+6 -> -2..+3 (price-basis). A +-2-3% move is 15-20x round-trip fees — no longer 'noise' worth locking - Strategy Studio gets an 'exit pacing' row (min hold / flat-close window / re-entry cooldown); thresholds editable via strategy JSON - Live strategy updated in data/data.db with the softened values
301 lines
8.0 KiB
Go
301 lines
8.0 KiB
Go
package trader
|
|
|
|
import (
|
|
"fmt"
|
|
"nofx/kernel"
|
|
"nofx/market"
|
|
"nofx/store"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// Anti-churn open caps: at most a couple of new positions per hour/cycle.
|
|
autopilotMaxOpensPerHour = 3
|
|
autopilotMaxOpensPerCycle = 2
|
|
)
|
|
|
|
// exitGates returns the strategy-configurable exit throttle: minimum hold,
|
|
// noise-close window, re-entry cooldown and the PRICE-basis PnL thresholds.
|
|
// Values come from the strategy's RiskControl (hot-reloaded from the DB) with
|
|
// built-in defaults for unset fields — see store.RiskControlConfig.
|
|
func (at *AutoTrader) exitGates() store.RiskControlConfig {
|
|
if at != nil && at.config.StrategyConfig != nil {
|
|
return at.config.StrategyConfig.RiskControl
|
|
}
|
|
return store.RiskControlConfig{}
|
|
}
|
|
|
|
// positionPricePnLPct converts the margin-based UnrealizedPnLPct reported for
|
|
// a position into the underlying price-move percentage.
|
|
func positionPricePnLPct(pos *kernel.PositionInfo) float64 {
|
|
if pos == nil {
|
|
return 0
|
|
}
|
|
if pos.Leverage > 1 {
|
|
return pos.UnrealizedPnLPct / float64(pos.Leverage)
|
|
}
|
|
return pos.UnrealizedPnLPct
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
reentryCooldown := at.exitGates().ReentryCooldown()
|
|
if order := at.findRecentCloseOrder(symbol, time.Now().Add(-reentryCooldown)); order != nil {
|
|
age := time.Since(time.UnixMilli(order.CreatedAt))
|
|
remaining := reentryCooldown - 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 ""
|
|
}
|
|
|
|
gates := at.exitGates()
|
|
minHold := gates.MinHold()
|
|
noiseHold := gates.NoiseHold()
|
|
|
|
pos := findContextPosition(ctx, symbol, side)
|
|
pnlPct := 0.0
|
|
entryTime := int64(0)
|
|
if pos != nil {
|
|
pnlPct = positionPricePnLPct(pos)
|
|
entryTime = pos.UpdateTime
|
|
}
|
|
|
|
if order := at.findRecentOpenOrder(symbol, side, time.Now().Add(-noiseHold)); 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 >= minHold {
|
|
if heldFor >= noiseHold ||
|
|
pnlPct <= gates.NoiseFloorPct() ||
|
|
pnlPct >= gates.NoiseCeilingPct() {
|
|
return ""
|
|
}
|
|
|
|
remaining := noiseHold - heldFor
|
|
return fmt.Sprintf(
|
|
"trade throttle: %s %s has been held for %s with price 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,
|
|
gates.NoiseFloorPct(),
|
|
gates.NoiseCeilingPct(),
|
|
roundDuration(remaining),
|
|
)
|
|
}
|
|
|
|
// Do not block true risk exits or unusually strong take-profit exits.
|
|
if pnlPct <= gates.StopBypassPct() || pnlPct >= gates.TPBypassPct() {
|
|
return ""
|
|
}
|
|
|
|
remaining := minHold - heldFor
|
|
return fmt.Sprintf(
|
|
"trade throttle: %s %s has only been held for %s with price PnL %.2f%%; min AI-managed hold is %s unless price loss <= %.1f%% or price profit >= %.1f%%",
|
|
symbol,
|
|
side,
|
|
roundDuration(heldFor),
|
|
pnlPct,
|
|
roundDuration(minHold),
|
|
gates.StopBypassPct(),
|
|
gates.TPBypassPct(),
|
|
) + 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()
|
|
}
|