Files
nofx/kernel/engine_position.go
tinkle-community b15c2da3a9 feat(strategy): English-only XYZ stock prompt + flat-account aggression + tier promote
The strategy prompt the LLM saw for a Chinese-language single-symbol US
stock trader was an incoherent zh/en patchwork — schema in Chinese,
role definition in English, hard constraints in English, custom prompt
back in Chinese — with crypto-flavored BTC/ETH vs Altcoin labelling that
made no sense for ARM-USDC. The LLM responded by being conservative and
boring. When it finally tried to open, the validator rejected the order
because the validator classified the stock as an altcoin (1x equity cap
= 112 USDT max) while the prompt said 5x cap (= 559 USDT).

- kernel/engine_prompt.go (BuildSystemPrompt): all eight prompt sections
  now respect e.GetLanguage() consistently. For single-symbol
  Hyperliquid XYZ assets (US stocks, commodities, forex) we additionally
  force the language to English regardless of the strategy's stored
  language — US-equity reasoning lands better in English and prevents
  the language-mix incoherence. The Hard Constraints section drops the
  BTC/ETH vs Altcoin two-tier split when the strategy trades a single
  instrument and shows one Position Value Limit line tagged with the
  actual symbol. The JSON example uses that symbol instead of the
  legacy BTCUSDT/ETHUSDT. The legacy stored custom_prompt (which was
  Chinese for stock quick-creates) is replaced for XYZ assets by
  buildXYZStockCustomPrompt — a built-in English long-only stock
  briefing that includes a Flat-Account Rule: when Current Positions
  is None, the agent MUST open a long this cycle (size 40-60% probing
  if technicals are mixed, 80-100% on a confirmed breakout). This is
  the "be in the market, not on the sidelines" stance the quick-trade
  flow needed; wait/hold are reserved for when a position already
  exists.

- kernel/engine_position.go + trader/auto_trader_risk.go + agent/trade.go:
  Hyperliquid XYZ assets now use the BTC/ETH higher tier rather than
  the altcoin tier in all three position-value enforcement points. A
  shared isMajorAsset / isMajorTradeSymbol helper treats BTC/ETH crypto
  perps AND any IsXyzDexAsset symbol as the higher tier. With 5x
  equity cap, the AI's confident-open decisions on US stocks now pass
  validation instead of erroring out with "altcoin single coin position
  value cannot exceed 112 USDT".

Net result: on a flat US-stock single-symbol trader, the agent opens
a sized position with stop-loss and take-profit on the very first
flat cycle, manages it (trail / partial / cut), and reports honestly
to the user. The "agent does nothing" complaint is closed.
2026-05-29 22:15:35 +08:00

134 lines
4.9 KiB
Go

package kernel
import (
"fmt"
"nofx/logger"
"nofx/market"
)
// ============================================================================
// Decision Validation
// ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}
return nil
}
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
validActions := map[string]bool{
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"hold": true,
"wait": true,
}
if !validActions[d.Action] {
return fmt.Errorf("invalid action: %s", d.Action)
}
if d.Action == "open_long" || d.Action == "open_short" {
// Asset tiering for validation:
// - BTC/ETH crypto perps use the BTC/ETH tier (typically 5x equity).
// - Hyperliquid XYZ assets (US equities, commodities, forex) are
// also treated as the higher tier — they are not crypto altcoins
// and the user's quick-trade flow shows them at the higher cap,
// so the validator must match.
// - Everything else is altcoin (1x equity by default).
maxLeverage := altcoinLeverage
posRatio := altcoinPosRatio
maxPositionValue := accountEquity * posRatio
isMajor := d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" || market.IsXyzDexAsset(d.Symbol)
if isMajor {
maxLeverage = btcEthLeverage
posRatio = btcEthPosRatio
maxPositionValue = accountEquity * posRatio
}
if d.Leverage <= 0 {
return fmt.Errorf("leverage must be greater than 0: %d", d.Leverage)
}
if d.Leverage > maxLeverage {
logger.Infof("⚠️ [Leverage Fallback] %s leverage exceeded (%dx > %dx), auto-adjusting to limit %dx",
d.Symbol, d.Leverage, maxLeverage, maxLeverage)
d.Leverage = maxLeverage
}
if d.PositionSizeUSD <= 0 {
return fmt.Errorf("position size must be greater than 0: %.2f", d.PositionSizeUSD)
}
const minPositionSizeGeneral = 12.0
const minPositionSizeBTCETH = 60.0
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
if d.PositionSizeUSD < minPositionSizeBTCETH {
return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH)
}
} else {
if d.PositionSizeUSD < minPositionSizeGeneral {
return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT", d.PositionSizeUSD, minPositionSizeGeneral)
}
}
tolerance := maxPositionValue * 0.01
if d.PositionSizeUSD > maxPositionValue+tolerance {
switch {
case d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT":
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
case market.IsXyzDexAsset(d.Symbol):
return fmt.Errorf("%s position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", d.Symbol, maxPositionValue, posRatio, d.PositionSizeUSD)
default:
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
}
}
if d.StopLoss <= 0 || d.TakeProfit <= 0 {
return fmt.Errorf("stop loss and take profit must be greater than 0")
}
if d.Action == "open_long" {
if d.StopLoss >= d.TakeProfit {
return fmt.Errorf("for long positions, stop loss price must be less than take profit price")
}
} else {
if d.StopLoss <= d.TakeProfit {
return fmt.Errorf("for short positions, stop loss price must be greater than take profit price")
}
}
var entryPrice float64
if d.Action == "open_long" {
entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2
} else {
entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2
}
var riskPercent, rewardPercent, riskRewardRatio float64
if d.Action == "open_long" {
riskPercent = (entryPrice - d.StopLoss) / entryPrice * 100
rewardPercent = (d.TakeProfit - entryPrice) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
} else {
riskPercent = (d.StopLoss - entryPrice) / entryPrice * 100
rewardPercent = (entryPrice - d.TakeProfit) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
}
if riskRewardRatio < 3.0 {
return fmt.Errorf("risk/reward ratio too low (%.2f:1), must be ≥3.0:1 [risk: %.2f%% reward: %.2f%%] [stop loss: %.2f take profit: %.2f]",
riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit)
}
}
return nil
}