mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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.
285 lines
8.8 KiB
Go
285 lines
8.8 KiB
Go
package trader
|
||
|
||
import (
|
||
"fmt"
|
||
"nofx/logger"
|
||
"nofx/market"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// startDrawdownMonitor starts drawdown monitoring
|
||
func (at *AutoTrader) startDrawdownMonitor() {
|
||
at.monitorWg.Add(1)
|
||
go func() {
|
||
defer at.monitorWg.Done()
|
||
|
||
ticker := time.NewTicker(1 * time.Minute) // Check every minute
|
||
defer ticker.Stop()
|
||
|
||
logger.Info("📊 Started position drawdown monitoring (check every minute)")
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
at.checkPositionDrawdown()
|
||
case <-at.stopMonitorCh:
|
||
logger.Info("⏹ Stopped position drawdown monitoring")
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// checkPositionDrawdown checks position drawdown situation
|
||
func (at *AutoTrader) checkPositionDrawdown() {
|
||
// Get current positions
|
||
positions, err := at.trader.GetPositions()
|
||
if err != nil {
|
||
logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err)
|
||
return
|
||
}
|
||
|
||
for _, pos := range positions {
|
||
symbol := pos["symbol"].(string)
|
||
side := pos["side"].(string)
|
||
entryPrice := pos["entryPrice"].(float64)
|
||
markPrice := pos["markPrice"].(float64)
|
||
quantity := pos["positionAmt"].(float64)
|
||
if quantity < 0 {
|
||
quantity = -quantity // Short position quantity is negative, convert to positive
|
||
}
|
||
|
||
// Guard: skip if entry price is zero (prevents division by zero panic)
|
||
if entryPrice <= 0 {
|
||
logger.Warnf("⚠️ Drawdown monitoring: %s %s has zero entry price, skipping", symbol, side)
|
||
continue
|
||
}
|
||
|
||
// Calculate current P&L percentage
|
||
leverage := 10 // Default value
|
||
if lev, ok := pos["leverage"].(float64); ok {
|
||
leverage = int(lev)
|
||
}
|
||
|
||
var currentPnLPct float64
|
||
if side == "long" {
|
||
currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100
|
||
} else {
|
||
currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100
|
||
}
|
||
|
||
// Construct unique position identifier (distinguish long/short)
|
||
posKey := symbol + "_" + side
|
||
|
||
// Get historical peak profit for this position
|
||
at.peakPnLCacheMutex.RLock()
|
||
peakPnLPct, exists := at.peakPnLCache[posKey]
|
||
at.peakPnLCacheMutex.RUnlock()
|
||
|
||
if !exists {
|
||
// If no historical peak record, use current P&L as initial value
|
||
peakPnLPct = currentPnLPct
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
} else {
|
||
// Update peak cache
|
||
at.UpdatePeakPnL(symbol, side, currentPnLPct)
|
||
}
|
||
|
||
// Calculate drawdown (magnitude of decline from peak)
|
||
var drawdownPct float64
|
||
if peakPnLPct > 0 && currentPnLPct < peakPnLPct {
|
||
drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100
|
||
}
|
||
|
||
// Check close position condition: profit > 5% and drawdown >= 40%
|
||
if currentPnLPct > 5.0 && drawdownPct >= 40.0 {
|
||
logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
|
||
// Execute close position
|
||
if err := at.emergencyClosePosition(symbol, side); err != nil {
|
||
logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err)
|
||
} else {
|
||
logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side)
|
||
// Clear cache for this position after closing
|
||
at.ClearPeakPnLCache(symbol, side)
|
||
}
|
||
} else if currentPnLPct > 5.0 {
|
||
// Record situations close to close position condition (for debugging)
|
||
logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%",
|
||
symbol, side, currentPnLPct, peakPnLPct, drawdownPct)
|
||
}
|
||
}
|
||
}
|
||
|
||
// emergencyClosePosition emergency close position function
|
||
func (at *AutoTrader) emergencyClosePosition(symbol, side string) error {
|
||
switch side {
|
||
case "long":
|
||
order, err := at.trader.CloseLong(symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"])
|
||
case "short":
|
||
order, err := at.trader.CloseShort(symbol, 0) // 0 = close all
|
||
if err != nil {
|
||
return err
|
||
}
|
||
logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"])
|
||
default:
|
||
return fmt.Errorf("unknown position direction: %s", side)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetPeakPnLCache gets peak profit cache
|
||
func (at *AutoTrader) GetPeakPnLCache() map[string]float64 {
|
||
at.peakPnLCacheMutex.RLock()
|
||
defer at.peakPnLCacheMutex.RUnlock()
|
||
|
||
// Return a copy of the cache
|
||
cache := make(map[string]float64)
|
||
for k, v := range at.peakPnLCache {
|
||
cache[k] = v
|
||
}
|
||
return cache
|
||
}
|
||
|
||
// UpdatePeakPnL updates peak profit cache
|
||
func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
if peak, exists := at.peakPnLCache[posKey]; exists {
|
||
// Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare)
|
||
if currentPnLPct > peak {
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
} else {
|
||
// First time recording
|
||
at.peakPnLCache[posKey] = currentPnLPct
|
||
}
|
||
}
|
||
|
||
// ClearPeakPnLCache clears peak cache for specified position
|
||
func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
|
||
at.peakPnLCacheMutex.Lock()
|
||
defer at.peakPnLCacheMutex.Unlock()
|
||
|
||
posKey := symbol + "_" + side
|
||
delete(at.peakPnLCache, posKey)
|
||
}
|
||
|
||
// ============================================================================
|
||
// Risk Control Helpers
|
||
// ============================================================================
|
||
|
||
// isBTCETH checks if a symbol is BTC or ETH
|
||
func isBTCETH(symbol string) bool {
|
||
symbol = strings.ToUpper(symbol)
|
||
return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH")
|
||
}
|
||
|
||
// isMajorAsset returns true for assets that should use the BTC/ETH higher
|
||
// position-value tier rather than the altcoin (1x equity) tier. This covers
|
||
// BTC/ETH crypto perps AND Hyperliquid XYZ assets (US equities, commodities,
|
||
// forex) — none of which are "altcoins" and all of which deserve the higher
|
||
// per-position cap so the AI can actually take meaningful positions.
|
||
func isMajorAsset(symbol string) bool {
|
||
if isBTCETH(symbol) {
|
||
return true
|
||
}
|
||
return market.IsXyzDexAsset(symbol)
|
||
}
|
||
|
||
// enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED)
|
||
// Returns the adjusted position size (capped if necessary) and whether the position was capped
|
||
// positionSizeUSD: the original position size in USD
|
||
// equity: the account equity
|
||
// symbol: the trading symbol
|
||
func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) {
|
||
if at.config.StrategyConfig == nil {
|
||
return positionSizeUSD, false
|
||
}
|
||
|
||
riskControl := at.config.StrategyConfig.RiskControl
|
||
|
||
// Get the appropriate position value ratio limit. BTC/ETH AND Hyperliquid
|
||
// XYZ assets (US stocks etc.) use the higher tier; pure altcoins use the
|
||
// lower tier.
|
||
var maxPositionValueRatio float64
|
||
if isMajorAsset(symbol) {
|
||
maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio
|
||
if maxPositionValueRatio <= 0 {
|
||
maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH and XYZ assets
|
||
}
|
||
} else {
|
||
maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio
|
||
if maxPositionValueRatio <= 0 {
|
||
maxPositionValueRatio = 1.0 // Default: 1x for altcoins
|
||
}
|
||
}
|
||
|
||
// Calculate max allowed position value = equity × ratio
|
||
maxPositionValue := equity * maxPositionValueRatio
|
||
|
||
// Check if position size exceeds limit
|
||
if positionSizeUSD > maxPositionValue {
|
||
logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping",
|
||
positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol)
|
||
return maxPositionValue, true
|
||
}
|
||
|
||
return positionSizeUSD, false
|
||
}
|
||
|
||
// enforceMinPositionSize checks minimum position size (CODE ENFORCED)
|
||
func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error {
|
||
if at.config.StrategyConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
minSize := at.config.StrategyConfig.RiskControl.MinPositionSize
|
||
if minSize <= 0 {
|
||
minSize = 12 // Default: 12 USDT
|
||
}
|
||
|
||
if positionSizeUSD < minSize {
|
||
return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// enforceMaxPositions checks maximum positions count (CODE ENFORCED)
|
||
func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
||
if at.config.StrategyConfig == nil {
|
||
return nil
|
||
}
|
||
|
||
maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions
|
||
if maxPositions <= 0 {
|
||
maxPositions = 3 // Default: 3 positions
|
||
}
|
||
|
||
if currentPositionCount >= maxPositions {
|
||
return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// getSideFromAction converts order action to side (BUY/SELL)
|
||
func getSideFromAction(action string) string {
|
||
switch action {
|
||
case "open_long", "close_short":
|
||
return "BUY"
|
||
case "open_short", "close_long":
|
||
return "SELL"
|
||
default:
|
||
return "BUY"
|
||
}
|
||
}
|