mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-18 01:44:38 +08:00
feat: simplify Claw402 autopilot trading flow
This commit is contained in:
@@ -52,9 +52,10 @@ func (at *AutoTrader) logErrorf(format string, args ...interface{}) {
|
||||
// AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions)
|
||||
type AutoTraderConfig struct {
|
||||
// Trader identification
|
||||
ID string // Trader unique identifier (for log directory, etc.)
|
||||
Name string // Trader display name
|
||||
AIModel string // AI model: "qwen" or "deepseek"
|
||||
ID string // Trader unique identifier (for log directory, etc.)
|
||||
Name string // Trader display name
|
||||
StrategyID string // Associated strategy ID used to refresh live strategy config
|
||||
AIModel string // AI model: "qwen" or "deepseek"
|
||||
|
||||
// Trading platform selection
|
||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
|
||||
@@ -121,7 +122,7 @@ type AutoTraderConfig struct {
|
||||
Claw402WalletKey string
|
||||
|
||||
// Scan configuration
|
||||
ScanInterval time.Duration // Scan interval (recommended 3 minutes)
|
||||
ScanInterval time.Duration // Scan interval (recommended 15 minutes)
|
||||
|
||||
// Account configuration
|
||||
InitialBalance float64 // Initial balance (for P&L calculation, must be set manually)
|
||||
@@ -138,7 +139,8 @@ type AutoTraderConfig struct {
|
||||
ShowInCompetition bool // Whether to show in competition page
|
||||
|
||||
// Strategy configuration (use complete strategy config)
|
||||
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
|
||||
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
|
||||
StrategyConfigRaw string // Raw strategy config JSON from DB, used to detect live edits
|
||||
}
|
||||
|
||||
// AutoTrader automatic trader
|
||||
@@ -396,6 +398,38 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (at *AutoTrader) reloadStrategyConfigIfChanged() error {
|
||||
if at == nil || at.store == nil || at.config.StrategyID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
strategy, err := at.store.Strategy().Get(at.userID, at.config.StrategyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load strategy %s: %w", at.config.StrategyID, err)
|
||||
}
|
||||
|
||||
if at.strategyEngine != nil && strategy.Config == at.config.StrategyConfigRaw {
|
||||
return nil
|
||||
}
|
||||
|
||||
strategyConfig, err := strategy.ParseConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse strategy %s: %w", strategy.Name, err)
|
||||
}
|
||||
strategyConfig.ClampLimits()
|
||||
|
||||
claw402Key := at.config.Claw402WalletKey
|
||||
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
|
||||
claw402Key = at.config.CustomAPIKey
|
||||
}
|
||||
|
||||
at.config.StrategyConfig = strategyConfig
|
||||
at.config.StrategyConfigRaw = strategy.Config
|
||||
at.strategyEngine = kernel.NewStrategyEngine(strategyConfig, claw402Key)
|
||||
at.logInfof("🔄 Strategy config refreshed from DB: %s", strategy.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run runs the automatic trading main loop
|
||||
func (at *AutoTrader) Run() error {
|
||||
at.isRunningMutex.Lock()
|
||||
|
||||
54
trader/auto_trader_full_size_test.go
Normal file
54
trader/auto_trader_full_size_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/kernel"
|
||||
"nofx/store"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyAutopilotFullSizeOpenForClaw402(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("en")
|
||||
cfg.CoinSource.SourceType = "vergex_signal"
|
||||
cfg.RiskControl.BTCETHMaxLeverage = 10
|
||||
cfg.RiskControl.AltcoinMaxLeverage = 10
|
||||
cfg.RiskControl.BTCETHMaxPositionValueRatio = 1
|
||||
cfg.RiskControl.AltcoinMaxPositionValueRatio = 1
|
||||
|
||||
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
|
||||
decision := &kernel.Decision{
|
||||
Symbol: "xyz:INTC",
|
||||
Action: "open_long",
|
||||
Leverage: 3,
|
||||
PositionSizeUSD: 12,
|
||||
}
|
||||
|
||||
at.applyAutopilotFullSizeOpen(decision, 29.8)
|
||||
|
||||
if decision.Leverage != 10 {
|
||||
t.Fatalf("expected leverage to be forced to 10x, got %dx", decision.Leverage)
|
||||
}
|
||||
if decision.PositionSizeUSD != 29.8 {
|
||||
t.Fatalf("expected position size to use full notional 29.8, got %.2f", decision.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAutopilotFullSizeOpenSkipsNonClaw402Strategies(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("en")
|
||||
cfg.CoinSource.SourceType = "static"
|
||||
cfg.RiskControl.BTCETHMaxLeverage = 10
|
||||
cfg.RiskControl.AltcoinMaxLeverage = 10
|
||||
|
||||
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
|
||||
decision := &kernel.Decision{
|
||||
Symbol: "BTCUSDT",
|
||||
Action: "open_long",
|
||||
Leverage: 3,
|
||||
PositionSizeUSD: 12,
|
||||
}
|
||||
|
||||
at.applyAutopilotFullSizeOpen(decision, 29.8)
|
||||
|
||||
if decision.Leverage != 3 || decision.PositionSizeUSD != 12 {
|
||||
t.Fatalf("non-Claw402 strategies should not be rewritten, got leverage=%d size=%.2f", decision.Leverage, decision.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ func (at *AutoTrader) runCycle() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := at.reloadStrategyConfigIfChanged(); err != nil {
|
||||
at.logWarnf("⚠️ Strategy refresh failed, using current in-memory config: %v", err)
|
||||
}
|
||||
|
||||
// Check USDC balance periodically for claw402 users (every 10 cycles)
|
||||
if at.callCount%10 == 0 && store.IsClaw402Config(at.config.AIModel) {
|
||||
at.checkClaw402Balance()
|
||||
@@ -250,7 +254,9 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute decisions and record results
|
||||
// Execute decisions and record results. Trade throttle is applied here,
|
||||
// immediately before order placement, so AI churn cannot become live orders.
|
||||
opensAllowedThisCycle := 0
|
||||
for _, d := range sortedDecisions {
|
||||
// Check if trader is stopped before each decision (allow immediate stop during execution)
|
||||
at.isRunningMutex.RLock()
|
||||
@@ -275,6 +281,17 @@ func (at *AutoTrader) runCycle() error {
|
||||
Success: false,
|
||||
}
|
||||
|
||||
if reason := at.tradeThrottleReason(d, ctx, opensAllowedThisCycle); reason != "" {
|
||||
at.logWarnf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason)
|
||||
actionRecord.Error = reason
|
||||
record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("🧊 %s %s blocked: %s", d.Symbol, d.Action, reason))
|
||||
record.Decisions = append(record.Decisions, actionRecord)
|
||||
continue
|
||||
}
|
||||
if isOpenAction(d.Action) {
|
||||
opensAllowedThisCycle++
|
||||
}
|
||||
|
||||
if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil {
|
||||
at.logErrorf("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err)
|
||||
actionRecord.Error = err.Error()
|
||||
@@ -740,7 +757,7 @@ func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||
func (at *AutoTrader) checkClaw402Balance() {
|
||||
scanMinutes := int(at.config.ScanInterval.Minutes())
|
||||
if scanMinutes <= 0 {
|
||||
scanMinutes = 3
|
||||
scanMinutes = 15
|
||||
}
|
||||
dailyCost, _ := store.EstimateRunway(1.0, at.config.CustomModelName, scanMinutes)
|
||||
logger.Infof("💰 [%s] Estimated daily AI cost: ~$%.2f (model: %s, interval: %dm)",
|
||||
|
||||
@@ -90,6 +90,8 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
at.applyAutopilotFullSizeOpen(decision, equity)
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
@@ -204,6 +206,8 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
|
||||
equity = availableBalance // Fallback to available balance
|
||||
}
|
||||
|
||||
at.applyAutopilotFullSizeOpen(decision, equity)
|
||||
|
||||
// [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio
|
||||
adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol)
|
||||
if wasCapped {
|
||||
|
||||
@@ -2,8 +2,10 @@ package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -237,6 +239,46 @@ func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity
|
||||
return positionSizeUSD, false
|
||||
}
|
||||
|
||||
func (at *AutoTrader) applyAutopilotFullSizeOpen(decision *kernel.Decision, equity float64) {
|
||||
if at == nil || decision == nil || at.config.StrategyConfig == nil || equity <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := at.config.StrategyConfig
|
||||
if cfg.CoinSource.SourceType != "vergex_signal" {
|
||||
return
|
||||
}
|
||||
|
||||
riskControl := cfg.RiskControl
|
||||
leverage := riskControl.AltcoinMaxLeverage
|
||||
positionValueRatio := riskControl.AltcoinMaxPositionValueRatio
|
||||
if isMajorAsset(decision.Symbol) {
|
||||
leverage = riskControl.BTCETHMaxLeverage
|
||||
positionValueRatio = riskControl.BTCETHMaxPositionValueRatio
|
||||
}
|
||||
if leverage < store.MinLeverage {
|
||||
leverage = store.MinLeverage
|
||||
}
|
||||
if leverage > store.MaxAltLeverage {
|
||||
leverage = store.MaxAltLeverage
|
||||
}
|
||||
if positionValueRatio <= 0 {
|
||||
positionValueRatio = 1.0
|
||||
}
|
||||
|
||||
fullPositionSize := equity * positionValueRatio
|
||||
if fullPositionSize <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if decision.Leverage != leverage || decision.PositionSizeUSD != fullPositionSize {
|
||||
logger.Infof(" 📏 [AUTOPILOT] Full-size open enforced for %s: leverage %dx → %dx, notional %.2f → %.2f USDT",
|
||||
decision.Symbol, decision.Leverage, leverage, decision.PositionSizeUSD, fullPositionSize)
|
||||
}
|
||||
decision.Leverage = leverage
|
||||
decision.PositionSizeUSD = fullPositionSize
|
||||
}
|
||||
|
||||
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
|
||||
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
|
||||
if at.config.StrategyConfig == nil {
|
||||
|
||||
278
trader/auto_trader_throttle.go
Normal file
278
trader/auto_trader_throttle.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
autopilotMinHoldDuration = 45 * time.Minute
|
||||
autopilotNoiseCloseHoldDuration = 90 * time.Minute
|
||||
autopilotReentryCooldown = 90 * time.Minute
|
||||
autopilotMaxOpensPerHour = 1
|
||||
autopilotMaxOpensPerCycle = 1
|
||||
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()
|
||||
}
|
||||
81
trader/auto_trader_throttle_test.go
Normal file
81
trader/auto_trader_throttle_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/kernel"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func throttleContext(symbol, side string, heldFor time.Duration, pnlPct float64) *kernel.Context {
|
||||
return &kernel.Context{
|
||||
Positions: []kernel.PositionInfo{
|
||||
{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
UpdateTime: time.Now().Add(-heldFor).UnixMilli(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -0.3)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if !strings.Contains(reason, "min AI-managed hold") {
|
||||
t.Fatalf("expected early close to be blocked by min hold, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if reason != "" {
|
||||
t.Fatalf("expected hard stop close to pass, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if !strings.Contains(reason, "noise band") {
|
||||
t.Fatalf("expected flat close to be blocked inside noise window, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if reason != "" {
|
||||
t.Fatalf("expected confirmed loss after min hold to pass, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksSecondOpenInCycle(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := &kernel.Context{}
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1)
|
||||
if !strings.Contains(reason, "only 1 new position") {
|
||||
t.Fatalf("expected second open in cycle to be blocked, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksOpeningAgainstExistingPosition(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 2*time.Hour, 1.0)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 0)
|
||||
if !strings.Contains(reason, "already has an open") {
|
||||
t.Fatalf("expected opposite open to be blocked when position exists, got %q", reason)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user