feat: simplify Claw402 autopilot trading flow

This commit is contained in:
tinkle-community
2026-06-27 00:37:59 +08:00
parent 961e016d33
commit 24f6421a73
151 changed files with 7453 additions and 41117 deletions

View File

@@ -10,11 +10,13 @@ import (
"nofx/market"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/provider/vergex"
"nofx/security"
"nofx/store"
"os"
"sort"
"strings"
"sync"
"time"
)
@@ -104,6 +106,7 @@ type Context struct {
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
OITopDataMap map[string]*OITopData `json:"-"`
QuantDataMap map[string]*QuantData `json:"-"`
VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"`
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
@@ -183,8 +186,10 @@ type OIDeltaData struct {
// StrategyEngine strategy execution engine
type StrategyEngine struct {
config *store.StrategyConfig
nofxosClient *nofxos.Client
config *store.StrategyConfig
nofxosClient *nofxos.Client
vergexClient *vergex.Client
vergexRankingCache map[string]*vergex.SignalRankItem
}
// NewStrategyEngine creates strategy execution engine.
@@ -217,11 +222,25 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
} else {
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
}
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
if err == nil {
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
} else {
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
}
return &StrategyEngine{
config: config,
nofxosClient: client,
vergexClient: vergexClient,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
return &StrategyEngine{
config: config,
nofxosClient: client,
config: config,
nofxosClient: client,
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
}
}
@@ -230,7 +249,7 @@ func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
return false
}
source := e.config.CoinSource
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.UseHyperAll || source.UseHyperMain {
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
return true
}
for _, symbol := range source.StaticCoins {
@@ -392,6 +411,20 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
return e.filterExcludedCoins(coins), nil
case "vergex_signal":
coins, err := e.getVergexSignalCoins(
coinSource.VergexLimit,
coinSource.VergexMarketType,
coinSource.VergexChain,
coinSource.VergexLiqBand,
coinSource.HyperRankCategory,
coinSource.StaticCoins,
)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "mixed":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -694,6 +727,126 @@ func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int
return candidates, nil
}
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
if e.vergexClient == nil {
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
}
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain = vergex.QueryChain(chain)
if limit <= 0 {
limit = 5
}
if limit > store.MaxCandidateCoins {
limit = store.MaxCandidateCoins
}
category = strings.ToLower(strings.TrimSpace(category))
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
Chain: chain,
LiqBand: liqBand,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
}
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
LiqBand: liqBand,
})
if fallbackErr == nil {
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
if len(fallbackItems) > 0 {
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
ranking = fallbackRanking
rankedItems = fallbackItems
}
} else {
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
}
}
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
for _, item := range rankedItems {
itemCopy := item
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
e.vergexRankingCache[symbol] = &itemCopy
}
}
if len(selectedSymbols) > 0 {
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
seen := make(map[string]bool)
for _, raw := range selectedSymbols {
symbol := vergex.TradableSymbolForMarket(marketType, raw)
if symbol == "" || seen[symbol] {
continue
}
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
seen[symbol] = true
if len(candidates) >= limit {
break
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
}
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
return candidates, nil
}
items := make([]vergex.SignalRankItem, 0, limit)
for _, item := range rankedItems {
if category != "" && category != "all" && item.Category != category {
continue
}
items = append(items, item)
if len(items) >= limit {
break
}
}
if len(items) == 0 {
if category != "" && category != "all" {
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
}
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
}
candidates := make([]CandidateCoin, 0, len(items))
for _, item := range items {
itemCopy := item
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
if symbol == "" {
continue
}
e.vergexRankingCache[symbol] = &itemCopy
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"vergex_signal"},
})
}
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
return candidates, nil
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func withDefaultText(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
// ============================================================================
// External & Quant Data
// ============================================================================
@@ -879,6 +1032,282 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
return result
}
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
result := make(map[string]*vergex.MarketAnalysis)
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
return result
}
if e.vergexClient == nil {
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
return result
}
if ctx == nil {
ctx = context.Background()
}
source := e.config.CoinSource
marketType := source.VergexMarketType
if marketType == "" {
marketType = vergex.DefaultMarketType
}
chain := source.VergexChain
chain = vergex.QueryChain(chain)
seen := make(map[string]bool)
limited := make([]string, 0, store.MaxCandidateCoins)
for _, symbol := range symbols {
symbol = vergexDetailSymbolForLookup(marketType, symbol)
if symbol == "" {
continue
}
if seen[symbol] {
continue
}
seen[symbol] = true
limited = append(limited, symbol)
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
break
}
}
type vergexAnalysisResult struct {
symbol string
analysis *vergex.MarketAnalysis
}
resultCh := make(chan vergexAnalysisResult, len(limited))
var wg sync.WaitGroup
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
for _, symbol := range limited {
symbol := symbol
querySymbol := vergex.QuerySymbol(symbol)
if querySymbol == "" {
continue
}
itemMarketType := marketType
itemCategory := ""
var ranking *vergex.SignalRankItem
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
ranking = cached
if cached.MarketType != "" {
itemMarketType = cached.MarketType
}
itemCategory = cached.Category
}
analysis := &vergex.MarketAnalysis{
Symbol: symbol,
QuerySymbol: querySymbol,
MarketType: itemMarketType,
Ranking: ranking,
}
query := vergex.Query{
MarketType: itemMarketType,
Symbol: symbol,
Chain: chain,
LiqBand: source.VergexLiqBand,
Category: itemCategory,
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
analysis.SignalLabError = ctx.Err().Error()
analysis.HeatmapError = ctx.Err().Error()
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
return
}
e.populateVergexDetailData(ctx, analysis, query)
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
}
}()
}
wg.Wait()
close(resultCh)
for item := range resultCh {
result[item.symbol] = item.analysis
}
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
return result
}
func vergexDetailSymbolForLookup(marketType, symbol string) string {
return vergex.TradableSymbolForMarket(marketType, symbol)
}
const (
vergexDetailRequestTimeout = 45 * time.Second
vergexDetailSymbolConcurrency = 2
)
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
type endpointResult struct {
name string
body json.RawMessage
err error
}
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
defer cancel()
body, err := fetch(requestCtx, query)
out <- endpointResult{name: name, body: body, err: err}
}
out := make(chan endpointResult, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
}()
go func() {
defer wg.Done()
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
}()
wg.Wait()
close(out)
for item := range out {
switch item.name {
case "signal-lab":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
analysis.SignalLabError = item.err.Error()
} else {
analysis.SignalLab = item.body
}
case "heatmap":
if item.err != nil {
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
analysis.HeatmapError = item.err.Error()
} else {
analysis.Heatmap = item.body
}
}
}
}
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
var lastErr error
for idx, candidate := range vergexDetailQueryCandidates(query) {
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
if err == nil {
if idx > 0 {
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
}
return body, nil
}
lastErr = err
if !isRetryableVergexDetailError(err) {
break
}
}
return nil, lastErr
}
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
marketTypes := vergexDetailMarketTypeCandidates(query)
chains := uniqueValues(query.Chain, "mainnet", "")
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
for _, marketType := range marketTypes {
for _, chain := range chains {
candidate := query
candidate.MarketType = marketType
candidate.Chain = chain
candidates = append(candidates, candidate)
}
}
return candidates
}
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
if isVergexAllMarketType(query.MarketType) {
if market.IsXyzDexAsset(query.Symbol) {
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
}
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
}
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
return uniqueNonEmpty(values...)
}
func isVergexAllMarketType(marketType string) bool {
switch strings.ToLower(strings.TrimSpace(marketType)) {
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
return true
default:
return false
}
}
func isRetryableVergexDetailError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "invalid markettype") ||
strings.Contains(msg, "invalid_request") ||
strings.Contains(msg, "invalid chain") ||
strings.Contains(msg, "market not found") ||
strings.Contains(msg, "not_found")
}
func uniqueNonEmpty(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func uniqueValues(values ...string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
// FetchOIRankingData fetches market-wide OI ranking data
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
indicators := e.config.Indicators

View File

@@ -85,6 +85,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
}
}
pruneCandidateCoinsWithoutMarketData(ctx)
enrichVergexDataWithStrategy(ctx, engine)
// Ensure OITopDataMap is initialized
if ctx.OITopDataMap == nil {
@@ -142,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
return decision, nil
}
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
return
}
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
return
}
symbolSet := make(map[string]bool)
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
for _, coin := range ctx.CandidateCoins {
if !symbolSet[coin.Symbol] {
symbolSet[coin.Symbol] = true
symbols = append(symbols, coin.Symbol)
}
}
for _, pos := range ctx.Positions {
if !symbolSet[pos.Symbol] {
symbolSet[pos.Symbol] = true
symbols = append(symbols, pos.Symbol)
}
}
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
}
// ============================================================================
// Market Data Fetching
// ============================================================================

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"nofx/market"
"nofx/provider/nofxos"
"nofx/provider/vergex"
"nofx/store"
"strings"
"time"
@@ -18,20 +19,15 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
var sb strings.Builder
riskControl := e.config.RiskControl
promptSections := e.config.PromptSections
lang := e.GetLanguage()
zh := lang == LangChinese
// System prompts are intentionally English-only. UI copy can be localized,
// but the model contract should stay language-stable for an international
// open-source project and for reproducible trading behavior.
lang := LangEnglish
zh := false
singleSymbol, primarySymbol := e.singleSymbolInfo()
// XYZ-only override: when the strategy trades a single Hyperliquid XYZ
// asset (US stocks, commodities, forex), force the entire prompt to
// English regardless of the strategy's stored language. Mixing Chinese
// reasoning with US-equity analysis confuses the LLM (its US-stock
// training is overwhelmingly English) and the user prompt sections
// ended up looking incoherent because some sections respect the
// language flag while legacy stored sections were always English.
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
zh = false
lang = LangEnglish
if e.usesVergexSignalPrompt() {
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
}
// 0. Data Dictionary & Schema (ensure AI understands all fields)
@@ -41,8 +37,9 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
// 1. Role definition (editable; falls back to a generic intro in the
// correct language so we don't mix EN headings with ZH custom text).
if promptSections.RoleDefinition != "" {
sb.WriteString(promptSections.RoleDefinition)
roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition)
if roleDefinition != "" {
sb.WriteString(roleDefinition)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# 你是一名专业的 Hyperliquid USDC 多资产交易 AI\n\n")
@@ -73,26 +70,28 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
// 4. Trading frequency (editable)
if promptSections.TradingFrequency != "" {
sb.WriteString(promptSections.TradingFrequency)
tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency)
if tradingFrequency != "" {
sb.WriteString(tradingFrequency)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# ⏱️ 交易频率提醒\n\n")
sb.WriteString("- 优秀交易员: 每日 2-4 单 ≈ 每小时 0.1-0.2 单\n")
sb.WriteString("- 每小时 > 2 单 = 过度交易\n")
sb.WriteString("- 单笔持仓时长 ≥ 30-60 分钟\n")
sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 30 分钟就平仓 → 太冲动。\n\n")
sb.WriteString("- 单笔持仓时长 ≥ 45-90 分钟\n")
sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 45 分钟就平仓 → 太冲动。\n\n")
} else {
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
sb.WriteString("- >2 trades/hour = overtrading\n")
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 30 minutes → too impulsive.\n\n")
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
}
// 5. Entry standards (editable)
if promptSections.EntryStandards != "" {
sb.WriteString(promptSections.EntryStandards)
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
if entryStandards != "" {
sb.WriteString(entryStandards)
if zh {
sb.WriteString("\n\n你拥有以下指标数据:\n")
} else {
@@ -117,8 +116,9 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
}
// 6. Decision process (editable)
if promptSections.DecisionProcess != "" {
sb.WriteString(promptSections.DecisionProcess)
decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess)
if decisionProcess != "" {
sb.WriteString(decisionProcess)
sb.WriteString("\n\n")
} else if zh {
sb.WriteString("# 📋 决策流程\n\n")
@@ -146,7 +146,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
// incoherent mixed-language final prompt that confused the LLM.
// 2. It guarantees a stock-specific, US-equity-tuned briefing
// regardless of when the strategy was first created.
customPrompt := e.config.CustomPrompt
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
}
@@ -169,43 +169,259 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
return sb.String()
}
// buildXYZStockCustomPrompt returns the canonical English long-only stock
func (e *StrategyEngine) usesVergexSignalPrompt() bool {
if e == nil || e.config == nil {
return false
}
coinSource := e.config.CoinSource
sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType))
return sourceType == "vergex_signal" ||
sourceType == "claw402" ||
sourceType == "claw402_vergex" ||
coinSource.VergexMarketType != "" ||
coinSource.VergexChain != "" ||
coinSource.VergexLimit > 0
}
func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string {
var sb strings.Builder
riskControl := e.config.RiskControl
writeVergexSchemaPrompt(&sb, zh)
sb.WriteString("\n\n---\n\n")
if zh {
sb.WriteString("# 你是 NOFX Claw402 自动交易员\n\n")
sb.WriteString("你的任务是交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。只允许交易本轮候选标的和已有持仓,不要自行发明代码或切换到榜单外标的。\n\n")
sb.WriteString("# 决策数据优先级\n\n")
sb.WriteString("1. Claw402.ai Signal Ranking: 决定本轮候选池、排名、方向和类别。\n")
sb.WriteString("2. Claw402.ai Signal Lab: 用于确认趋势、动量、事件或模型信号;这是开仓前的核心确认数据。\n")
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: 用于识别清算密集区、成本区、止损位置和止盈目标。\n")
sb.WriteString("4. 原始 OHLCV K 线: 用于验证入场时机、趋势结构、波动和风险回报。\n\n")
sb.WriteString("# 交易原则\n\n")
sb.WriteString("- 先管理已有持仓,再考虑新开仓。\n")
sb.WriteString("- 开仓需要 Signal Lab、热力图和 K 线方向大体一致;任一关键数据缺失或互相冲突时,默认等待。\n")
sb.WriteString("- 不要把 Claw402 排名当作唯一买入理由;排名只是候选池,开仓必须经过详情数据和 K 线确认。\n")
sb.WriteString("- 本轮 Candidate Coins 中的标的都是允许交易的候选;如果某个标的详情缺失,只能降低置信度或等待,不能说它不属于可交易范围。\n")
sb.WriteString("- 如果 Signal Lab 或热力图没有出现在该标的的 Vergex Claw402 Signals 里,必须在 reasoning 中说明缺失;如果已经出现,则不能声称该标的缺少该数据。\n")
sb.WriteString("- 防止频繁开平仓:非止损或强止盈情况下,开仓后至少持有 45 分钟;小亏小赚的噪音区优先持有到 90 分钟;平仓后同一标的 90 分钟内不重新进场;每小时最多 1 次新开仓。\n")
sb.WriteString("- 止损必须放在无效点之外;止盈优先放在热力图阻力/清算区域或满足风险回报的位置。\n\n")
} else {
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
sb.WriteString("# Decision Data Priority\n\n")
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
sb.WriteString("# Trading Rules\n\n")
sb.WriteString("- Manage existing positions before opening new ones.\n")
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n")
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n")
}
writeModeVariant(&sb, variant, zh)
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
if altcoinPosValueRatio <= 0 {
altcoinPosValueRatio = 1.0
}
writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh)
writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
if customPrompt != "" {
sb.WriteString("# User Preference\n\n")
sb.WriteString(customPrompt)
sb.WriteString("\n\n")
}
return sb.String()
}
func englishOnlyPromptSection(section string) string {
trimmed := strings.TrimSpace(section)
if trimmed == "" {
return ""
}
if detectLanguage(trimmed) == LangChinese {
return ""
}
return trimmed
}
func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
if zh {
sb.WriteString("# Claw402.ai TradeFi 数据说明\n\n")
sb.WriteString("- Equity: 账户总权益,包含浮动盈亏,单位 USDT。\n")
sb.WriteString("- Balance: 可用余额,用于判断还能否开新仓,单位 USDT。\n")
sb.WriteString("- Margin: 当前保证金使用率,越高风险越大。\n")
sb.WriteString("- Position: 当前持仓,包含方向、进场价、杠杆、未实现盈亏、强平价。\n")
sb.WriteString("- Claw402 Ranking: 本轮可交易候选池、排名、方向和类别。\n")
sb.WriteString("- Signal Lab: Claw402 对单个标的的深度信号,用于确认趋势和质量。\n")
sb.WriteString("- Cost/Liquidation Heatmap: 成本区与清算密集区,用于止损、止盈和拥挤风险判断。\n")
sb.WriteString("- Raw OHLCV Kline: 原始 K 线,用于确认趋势结构、入场位置和风险回报。\n")
} else {
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
}
}
func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) {
maxPositionValue := accountEquity * tradeFiPositionValueRatio
if zh {
sb.WriteString("# 风控硬约束\n\n")
sb.WriteString("## 后端强制\n")
sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个 Claw402 候选标的\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- 单仓最大名义价值: %.0f USDT (= 权益 %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
sb.WriteString(fmt.Sprintf("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI 建议\n")
sb.WriteString(fmt.Sprintf("- 交易杠杆: Claw402 候选标的最高 %dx\n", riskControl.AltcoinMaxLeverage))
sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才能开仓\n\n", riskControl.MinConfidence))
sb.WriteString("# 仓位大小\n\n")
sb.WriteString("根据置信度和单仓最大名义价值填写 `position_size_usd`:\n")
sb.WriteString("- 高置信 (≥85): 使用上限的 80-100%\n")
sb.WriteString("- 中置信 (70-84): 使用上限的 50-80%\n")
sb.WriteString("- 低置信 (60-69): 使用上限的 30-50%\n")
sb.WriteString("- 不要直接把 available_balance 当作 position_size_usd。\n\n")
} else {
sb.WriteString("# Hard Risk Constraints\n\n")
sb.WriteString("## Backend enforced\n")
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
sb.WriteString("## AI guided\n")
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
sb.WriteString("# Position Sizing\n\n")
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
sb.WriteString("- Do not open small probe positions.\n")
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
}
}
func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
exampleSymbol := "xyz:NVDA"
secondSymbol := "xyz:AAPL"
if singleSymbol && strings.TrimSpace(primarySymbol) != "" {
exampleSymbol = primarySymbol
secondSymbol = primarySymbol
}
positionSize := accountEquity * tradeFiPositionValueRatio
leverage := riskControl.AltcoinMaxLeverage
if leverage <= 0 {
leverage = 1
}
sb.WriteString("# Output Format (Strictly Follow)\n\n")
if zh {
sb.WriteString("必须使用 XML 标签 <reasoning> 和 <decision> 分隔简明分析和决策 JSON。\n\n")
sb.WriteString("方向必须由数据决定:上涨结构确认时可以 `open_long`,下跌结构确认时可以 `open_short`;不要默认只做多或只做空。\n\n")
} else {
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
}
sb.WriteString("<reasoning>\n")
if zh {
sb.WriteString("简明说明: Claw402 排名、Signal Lab、热力图、K 线是否一致;如果缺数据或冲突,说明为什么等待。\n")
} else {
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
}
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
sb.WriteString("```json\n[\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize))
} else {
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize))
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize))
}
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
if zh {
sb.WriteString("## 字段要求\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100开仓建议 ≥ %d\n", riskControl.MinConfidence))
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- 所有数值必须是算好的数字,不能写公式。\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- 本策略只交易 `%s`JSON 的 symbol 必须完全等于它。\n", exampleSymbol))
} else {
sb.WriteString("- JSON 的 symbol 必须完全来自本轮候选标的或已有持仓;`xyz:` 标的保留前缀core crypto 标的不要添加 `xyz:` 或 `USDT` 后缀。\n")
}
sb.WriteString("\n")
} else {
sb.WriteString("## Field Requirements\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
if singleSymbol {
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
} else {
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
}
sb.WriteString("\n")
}
}
// buildXYZStockCustomPrompt returns the canonical English directional stock
// briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on
// the XYZ board. This is the quick-trade flow's stance: when the user clicks
// the lightning button they want exposure NOW, not a watch-and-wait analyst.
// We bias the AI toward opening a probing long on every flat cycle, with
// risk guardrails to keep the size sane. Symbol is inlined for LLM grounding
// so it never confuses the trading instrument.
// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the
// trading instrument.
func buildXYZStockCustomPrompt(symbol string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol))
sb.WriteString("Core stance: LONG-ONLY, ACTIVE. Do not short. Do not sit on the sidelines.\n\n")
sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n")
sb.WriteString("## Flat-Account Rule (HIGHEST PRIORITY)\n")
sb.WriteString("If `Current Positions` is None / empty AND no open exposure on this symbol, you MUST open a long position THIS cycle.\n")
sb.WriteString("- The user is paying for the quick-trade flow specifically to be in the market — your default is OPEN, not WAIT.\n")
sb.WriteString("- `wait` / `hold` are only acceptable when you ALREADY have an open position (then it's a hold decision).\n")
sb.WriteString("- If you cannot find a clear breakout setup, take a probing entry at market with reduced size (40-60% of max position value) and a tight 2% stop. Probing > waiting.\n")
sb.WriteString("- Set `confidence` >= 75 so backend risk control accepts the order. Justify the 75 in reasoning even if the technical picture is mixed — the strategic decision to be exposed IS the edge for this flow.\n\n")
sb.WriteString("## Flat-Account Rule\n")
sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n")
sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n")
sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n")
sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n")
sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n")
sb.WriteString("## Preferred Entry Conditions (use to size up, not to gate the entry)\n")
sb.WriteString("Size up toward the 80-100% bracket when you see ANY of:\n")
sb.WriteString("## Long Entry Conditions\n")
sb.WriteString("- Break of the prior session/intraday high on rising volume.\n")
sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n")
sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n")
sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n")
sb.WriteString("## Short Entry Conditions\n")
sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n")
sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n")
sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n")
sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n")
sb.WriteString("## Risk Guardrails (non-negotiable)\n")
sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n")
sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n")
sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n")
sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n")
sb.WriteString("- Once long, do NOT short the same cycle. Manage the open position first.\n\n")
sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n")
sb.WriteString("## Position Management (when already long)\n")
sb.WriteString("## Position Management\n")
sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n")
sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n")
sb.WriteString("- Holding past 30 minutes is fine; flipping in/out every cycle is not.\n\n")
sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n")
sb.WriteString("## Discipline\n")
sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol))
@@ -220,7 +436,7 @@ func buildXYZStockCustomPrompt(symbol string) string {
// to put the actual trading symbol into the JSON example.
func (e *StrategyEngine) singleSymbolInfo() (bool, string) {
coinSource := e.config.CoinSource
if coinSource.SourceType == "static" && len(coinSource.StaticCoins) == 1 {
if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 {
return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0]))
}
return false, ""
@@ -637,6 +853,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
sb.WriteString(e.formatQuantData(quantData))
}
}
if ctx.VergexDataMap != nil {
if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex {
sb.WriteString(e.formatVergexData(vergexData))
}
}
sb.WriteString("\n")
}
sb.WriteString("\n")
@@ -663,7 +884,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
}
sb.WriteString("---\n\n")
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
sb.WriteString("Now please analyze briefly and output the decision JSON.\n")
return sb.String()
}
@@ -702,6 +923,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
sb.WriteString(e.formatQuantData(quantData))
}
}
if ctx.VergexDataMap != nil {
if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex {
sb.WriteString(e.formatVergexData(vergexData))
}
}
sb.WriteString("\n")
}
@@ -760,11 +986,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
case "vergex_signal":
return " (Vergex Signal)"
}
if strings.HasPrefix(sources[0], "hyper_rank") {
return " (Hyperliquid Dynamic Rank)"
}
}
return ""
}
func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string {
if data == nil {
return ""
}
var sb strings.Builder
sb.WriteString("\nVergex Claw402 Signals:\n")
sb.WriteString(vergex.FormatAnalysisForAI(data))
return sb.String()
}
// ============================================================================
// Market Data Formatting
// ============================================================================

View File

@@ -0,0 +1,124 @@
package kernel
import (
"strings"
"testing"
"nofx/store"
)
func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
cfg := store.GetDefaultStrategyConfig("zh")
cfg.CoinSource.SourceType = "vergex_signal"
cfg.CoinSource.VergexLimit = 5
cfg.PromptSections.RoleDefinition = "# 你是一个专业的 Hyperliquid USDC 多资产交易AI"
cfg.CustomPrompt = "只做多,不做空。"
engine := NewStrategyEngine(&cfg)
prompt := engine.BuildSystemPrompt(30, "balanced")
if !strings.Contains(prompt, "NOFX Claw402 auto-trader") {
t.Fatalf("prompt did not use the Claw402/Vergex TradeFi role:\n%s", prompt)
}
if !strings.Contains(prompt, "Claw402.ai Signal Ranking") || !strings.Contains(prompt, "Signal Lab") || !strings.Contains(prompt, "Cost/Liquidation Heatmap") {
t.Fatalf("prompt is missing Claw402/Vergex detail data guidance:\n%s", prompt)
}
if !strings.Contains(prompt, "open_short") {
t.Fatalf("prompt should explicitly allow short entries:\n%s", prompt)
}
if !strings.Contains(prompt, "Direction must be data-driven") {
t.Fatalf("prompt should explain that direction is data-driven, not long-only:\n%s", prompt)
}
if !strings.Contains(prompt, "every open position must use exactly 10x") {
t.Fatalf("prompt should force 10x leverage for Claw402 opens:\n%s", prompt)
}
if !strings.Contains(prompt, "use the full max notional per position") {
t.Fatalf("prompt should force full-size Claw402 opens:\n%s", prompt)
}
if containsCJK(prompt) {
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
}
legacyPhrases := []string{
"Hyperliquid USDC 多资产交易AI",
"只做多",
"山寨币",
"BTC/ETH",
"LONG-ONLY",
"Do not short",
"MUST open a long",
}
for _, phrase := range legacyPhrases {
if strings.Contains(prompt, phrase) {
t.Fatalf("prompt still contains legacy phrase %q:\n%s", phrase, prompt)
}
}
}
func TestBuildSystemPromptFallsBackToEnglishWhenConfiguredLanguageIsChinese(t *testing.T) {
cfg := store.GetDefaultStrategyConfig("zh")
cfg.CoinSource.SourceType = "static"
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
cfg.CoinSource.VergexLimit = 0
cfg.CoinSource.VergexMarketType = ""
cfg.CoinSource.VergexChain = ""
cfg.PromptSections.RoleDefinition = "# 你是中文系统提示词"
cfg.PromptSections.TradingFrequency = "# 高频交易\n每分钟交易。"
cfg.PromptSections.EntryStandards = "# 入场\n随便开仓。"
cfg.PromptSections.DecisionProcess = "# 决策\n直接输出。"
cfg.CustomPrompt = "中文偏好不应进入系统提示词。"
engine := NewStrategyEngine(&cfg)
prompt := engine.BuildSystemPrompt(30, "balanced")
required := []string{
"Data Dictionary & Trading Rules",
"You are a professional Hyperliquid USDC multi-asset trading AI",
"Trading Frequency Awareness",
"Entry Standards",
"Decision Process",
}
for _, phrase := range required {
if !strings.Contains(prompt, phrase) {
t.Fatalf("English fallback prompt missing %q:\n%s", phrase, prompt)
}
}
if containsCJK(prompt) {
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
}
}
func TestBuildSystemPromptDoesNotForceLongOnlyForSingleXYZ(t *testing.T) {
prompt := buildXYZStockCustomPrompt("XYZ:INTC")
required := []string{
"DIRECTIONAL, SIGNAL-DRIVEN",
"You may open long or short",
"open_short",
}
for _, phrase := range required {
if !strings.Contains(prompt, phrase) {
t.Fatalf("single XYZ prompt missing %q:\n%s", phrase, prompt)
}
}
forbidden := []string{
"LONG-ONLY",
"Do not short",
"MUST open a long",
"Probing > waiting",
}
for _, phrase := range forbidden {
if strings.Contains(prompt, phrase) {
t.Fatalf("single XYZ prompt still contains forced-long phrase %q:\n%s", phrase, prompt)
}
}
}
func containsCJK(text string) bool {
for _, r := range text {
if r >= 0x4E00 && r <= 0x9FFF {
return true
}
}
return false
}

View File

@@ -0,0 +1,107 @@
package kernel
import (
"testing"
"nofx/provider/vergex"
)
func TestVergexDetailQueryCandidatesUseHIP3MarketAndMainnetChain(t *testing.T) {
candidates := vergexDetailQueryCandidates(vergex.Query{
MarketType: vergex.DefaultMarketType,
Symbol: "xyz:INTC",
Chain: vergex.DefaultChain,
Category: "stock",
})
if len(candidates) == 0 {
t.Fatal("expected detail query candidates")
}
if candidates[0].MarketType != "hip3_perp" || candidates[0].Chain != "mainnet" {
t.Fatalf("first candidate = %+v, want hip3_perp/mainnet", candidates[0])
}
if !hasVergexDetailCandidate(candidates, "hip3_perp", "") {
t.Fatalf("expected hip3_perp/default-chain fallback in %+v", candidates)
}
if hasVergexDetailCandidate(candidates, "stock", "mainnet") {
t.Fatalf("did not expect stock marketType fallback for Vergex detail endpoint: %+v", candidates)
}
}
func TestVergexDetailSymbolForLookupKeepsCoreCryptoBaseSymbols(t *testing.T) {
cases := []struct {
name string
marketType string
symbol string
want string
}{
{
name: "core crypto from all board",
marketType: "all",
symbol: "AAVE",
want: "AAVE",
},
{
name: "core crypto with usdt suffix",
marketType: "all",
symbol: "HYPEUSDT",
want: "HYPE",
},
{
name: "xyz stock keeps xyz prefix",
marketType: "all",
symbol: "xyz:INTC",
want: "xyz:INTC",
},
{
name: "hip3 symbol gains xyz prefix",
marketType: vergex.DefaultMarketType,
symbol: "SNDK",
want: "xyz:SNDK",
},
{
name: "core market strips suffix",
marketType: "core_perp",
symbol: "LITUSDT",
want: "LIT",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := vergexDetailSymbolForLookup(tc.marketType, tc.symbol); got != tc.want {
t.Fatalf("vergexDetailSymbolForLookup(%q, %q) = %q, want %q", tc.marketType, tc.symbol, got, tc.want)
}
})
}
}
func TestVergexDetailQueryCandidatesPreferMarketTypeBySymbolWhenSourceIsAll(t *testing.T) {
cryptoCandidates := vergexDetailQueryCandidates(vergex.Query{
MarketType: "all",
Symbol: "AAVE",
Chain: "mainnet",
})
if len(cryptoCandidates) == 0 || cryptoCandidates[0].MarketType != "core_perp" {
t.Fatalf("crypto candidates should prefer core_perp first: %+v", cryptoCandidates)
}
xyzCandidates := vergexDetailQueryCandidates(vergex.Query{
MarketType: "all",
Symbol: "xyz:SNDK",
Chain: "mainnet",
})
if len(xyzCandidates) == 0 || xyzCandidates[0].MarketType != vergex.DefaultMarketType {
t.Fatalf("xyz candidates should prefer hip3_perp first: %+v", xyzCandidates)
}
}
func hasVergexDetailCandidate(candidates []vergex.Query, marketType, chain string) bool {
for _, candidate := range candidates {
if candidate.MarketType == marketType && candidate.Chain == chain {
return true
}
}
return false
}