mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
refactor: split large files and clean up project structure
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
This commit is contained in:
1285
kernel/engine.go
1285
kernel/engine.go
File diff suppressed because it is too large
Load Diff
374
kernel/engine_analysis.go
Normal file
374
kernel/engine_analysis.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Pre-compiled regular expressions (performance optimization)
|
||||
// ============================================================================
|
||||
|
||||
var (
|
||||
// Safe regex: precisely match ```json code blocks
|
||||
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
|
||||
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
|
||||
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
|
||||
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
|
||||
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
|
||||
|
||||
// XML tag extraction (supports any characters in reasoning chain)
|
||||
reReasoningTag = regexp.MustCompile(`(?s)<reasoning>(.*?)</reasoning>`)
|
||||
reDecisionTag = regexp.MustCompile(`(?s)<decision>(.*?)</decision>`)
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Entry Functions - Main API
|
||||
// ============================================================================
|
||||
|
||||
// GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions)
|
||||
// Uses default strategy configuration - for production use GetFullDecisionWithStrategy with explicit config
|
||||
func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) {
|
||||
defaultConfig := store.GetDefaultStrategyConfig("en")
|
||||
engine := NewStrategyEngine(&defaultConfig)
|
||||
return GetFullDecisionWithStrategy(ctx, mcpClient, engine, "")
|
||||
}
|
||||
|
||||
// GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (unified prompt generation)
|
||||
func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) {
|
||||
if ctx == nil {
|
||||
return nil, fmt.Errorf("context is nil")
|
||||
}
|
||||
if engine == nil {
|
||||
defaultConfig := store.GetDefaultStrategyConfig("en")
|
||||
engine = NewStrategyEngine(&defaultConfig)
|
||||
}
|
||||
|
||||
// 1. Fetch market data using strategy config
|
||||
if len(ctx.MarketDataMap) == 0 {
|
||||
if err := fetchMarketDataWithStrategy(ctx, engine); err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||
if err == nil {
|
||||
for _, pos := range oiPositions {
|
||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||
Rank: pos.Rank,
|
||||
OIDeltaPercent: pos.OIDeltaPercent,
|
||||
OIDeltaValue: pos.OIDeltaValue,
|
||||
PriceDeltaPercent: pos.PriceDeltaPercent,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build System Prompt using strategy engine
|
||||
riskConfig := engine.GetRiskControlConfig()
|
||||
systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant)
|
||||
|
||||
// 3. Build User Prompt using strategy engine
|
||||
userPrompt := engine.BuildUserPrompt(ctx)
|
||||
|
||||
// 4. Call AI API
|
||||
aiCallStart := time.Now()
|
||||
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
aiCallDuration := time.Since(aiCallStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI API call failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Parse AI response
|
||||
decision, err := parseFullDecisionResponse(
|
||||
aiResponse,
|
||||
ctx.Account.TotalEquity,
|
||||
riskConfig.BTCETHMaxLeverage,
|
||||
riskConfig.AltcoinMaxLeverage,
|
||||
riskConfig.BTCETHMaxPositionValueRatio,
|
||||
riskConfig.AltcoinMaxPositionValueRatio,
|
||||
)
|
||||
|
||||
if decision != nil {
|
||||
decision.Timestamp = time.Now()
|
||||
decision.SystemPrompt = systemPrompt
|
||||
decision.UserPrompt = userPrompt
|
||||
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
|
||||
decision.RawResponse = aiResponse
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return decision, fmt.Errorf("failed to parse AI response: %w", err)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Fetching
|
||||
// ============================================================================
|
||||
|
||||
// fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes)
|
||||
func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
config := engine.GetConfig()
|
||||
ctx.MarketDataMap = make(map[string]*market.Data)
|
||||
|
||||
timeframes := config.Indicators.Klines.SelectedTimeframes
|
||||
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
|
||||
klineCount := config.Indicators.Klines.PrimaryCount
|
||||
|
||||
// Compatible with old configuration
|
||||
if len(timeframes) == 0 {
|
||||
if primaryTimeframe != "" {
|
||||
timeframes = append(timeframes, primaryTimeframe)
|
||||
} else {
|
||||
timeframes = append(timeframes, "3m")
|
||||
}
|
||||
if config.Indicators.Klines.LongerTimeframe != "" {
|
||||
timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe)
|
||||
}
|
||||
}
|
||||
if primaryTimeframe == "" {
|
||||
primaryTimeframe = timeframes[0]
|
||||
}
|
||||
if klineCount <= 0 {
|
||||
klineCount = 30
|
||||
}
|
||||
|
||||
logger.Infof("📊 Strategy timeframes: %v, Primary: %s, Kline count: %d", timeframes, primaryTimeframe, klineCount)
|
||||
|
||||
// 1. First fetch data for position coins (must fetch)
|
||||
for _, pos := range ctx.Positions {
|
||||
data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to fetch market data for position %s: %v", pos.Symbol, err)
|
||||
continue
|
||||
}
|
||||
ctx.MarketDataMap[pos.Symbol] = data
|
||||
}
|
||||
|
||||
// 2. Fetch data for all candidate coins
|
||||
positionSymbols := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
positionSymbols[pos.Symbol] = true
|
||||
}
|
||||
|
||||
const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value
|
||||
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if _, exists := ctx.MarketDataMap[coin.Symbol]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to fetch market data for %s: %v", coin.Symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance)
|
||||
isExistingPosition := positionSymbols[coin.Symbol]
|
||||
isXyzAsset := market.IsXyzDexAsset(coin.Symbol)
|
||||
if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 {
|
||||
oiValue := data.OpenInterest.Latest * data.CurrentPrice
|
||||
oiValueInMillions := oiValue / 1_000_000
|
||||
if oiValueInMillions < minOIThresholdMillions {
|
||||
logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin",
|
||||
coin.Symbol, oiValueInMillions, minOIThresholdMillions)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ctx.MarketDataMap[coin.Symbol] = data
|
||||
}
|
||||
|
||||
logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) (*FullDecision, error) {
|
||||
cotTrace := extractCoTTrace(aiResponse)
|
||||
|
||||
decisions, err := extractDecisions(aiResponse)
|
||||
if err != nil {
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: []Decision{},
|
||||
}, fmt.Errorf("failed to extract decisions: %w", err)
|
||||
}
|
||||
|
||||
if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
}, fmt.Errorf("decision validation failed: %w", err)
|
||||
}
|
||||
|
||||
return &FullDecision{
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractCoTTrace(response string) string {
|
||||
if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 {
|
||||
logger.Infof("✓ Extracted reasoning chain using <reasoning> tag")
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
if decisionIdx := strings.Index(response, "<decision>"); decisionIdx > 0 {
|
||||
logger.Infof("✓ Extracted content before <decision> tag as reasoning chain")
|
||||
return strings.TrimSpace(response[:decisionIdx])
|
||||
}
|
||||
|
||||
jsonStart := strings.Index(response, "[")
|
||||
if jsonStart > 0 {
|
||||
logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)")
|
||||
return strings.TrimSpace(response[:jsonStart])
|
||||
}
|
||||
|
||||
return strings.TrimSpace(response)
|
||||
}
|
||||
|
||||
func extractDecisions(response string) ([]Decision, error) {
|
||||
s := removeInvisibleRunes(response)
|
||||
s = strings.TrimSpace(s)
|
||||
s = fixMissingQuotes(s)
|
||||
|
||||
var jsonPart string
|
||||
if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 {
|
||||
jsonPart = strings.TrimSpace(match[1])
|
||||
logger.Infof("✓ Extracted JSON using <decision> tag")
|
||||
} else {
|
||||
jsonPart = s
|
||||
logger.Infof("⚠️ <decision> tag not found, searching JSON in full text")
|
||||
}
|
||||
|
||||
jsonPart = fixMissingQuotes(jsonPart)
|
||||
|
||||
if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 {
|
||||
jsonContent := strings.TrimSpace(m[1])
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
|
||||
}
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart))
|
||||
if jsonContent == "" {
|
||||
logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode")
|
||||
|
||||
cotSummary := jsonPart
|
||||
if len(cotSummary) > 240 {
|
||||
cotSummary = cotSummary[:240] + "..."
|
||||
}
|
||||
|
||||
fallbackDecision := Decision{
|
||||
Symbol: "ALL",
|
||||
Action: "wait",
|
||||
Reasoning: fmt.Sprintf("Model didn't output structured JSON decision, entering safe wait; summary: %s", cotSummary),
|
||||
}
|
||||
|
||||
return []Decision{fallbackDecision}, nil
|
||||
}
|
||||
|
||||
jsonContent = compactArrayOpen(jsonContent)
|
||||
jsonContent = fixMissingQuotes(jsonContent)
|
||||
|
||||
if err := validateJSONFormat(jsonContent); err != nil {
|
||||
return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response)
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent)
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
func fixMissingQuotes(jsonStr string) string {
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "[", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "]", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "{", "{")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "}", "}")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ":", ":")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, ",", ",")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "【", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "】", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〔", "[")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "〕", "]")
|
||||
jsonStr = strings.ReplaceAll(jsonStr, "、", ",")
|
||||
|
||||
jsonStr = strings.ReplaceAll(jsonStr, " ", " ")
|
||||
|
||||
return jsonStr
|
||||
}
|
||||
|
||||
func validateJSONFormat(jsonStr string) error {
|
||||
trimmed := strings.TrimSpace(jsonStr)
|
||||
|
||||
if !reArrayHead.MatchString(trimmed) {
|
||||
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
|
||||
return fmt.Errorf("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))])
|
||||
}
|
||||
return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))])
|
||||
}
|
||||
|
||||
if strings.Contains(jsonStr, "~") {
|
||||
return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values")
|
||||
}
|
||||
|
||||
for i := 0; i < len(jsonStr)-4; i++ {
|
||||
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
|
||||
jsonStr[i+1] == ',' &&
|
||||
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
|
||||
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
|
||||
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
|
||||
return fmt.Errorf("JSON numbers cannot contain thousand separator comma, found: %s", jsonStr[i:min(i+10, len(jsonStr))])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func removeInvisibleRunes(s string) string {
|
||||
return reInvisibleRunes.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func compactArrayOpen(s string) string {
|
||||
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
|
||||
}
|
||||
121
kernel/engine_position.go
Normal file
121
kernel/engine_position.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 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" {
|
||||
maxLeverage := altcoinLeverage
|
||||
posRatio := altcoinPosRatio
|
||||
maxPositionValue := accountEquity * posRatio
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
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 {
|
||||
if 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)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
779
kernel/engine_prompt.go
Normal file
779
kernel/engine_prompt.go
Normal file
@@ -0,0 +1,779 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - System Prompt
|
||||
// ============================================================================
|
||||
|
||||
// BuildSystemPrompt builds System Prompt according to strategy configuration
|
||||
func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("---\n\n")
|
||||
|
||||
// 1. Role definition (editable)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
|
||||
}
|
||||
|
||||
// 2. Trading mode variant
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
|
||||
// 3. Hard constraints (risk control)
|
||||
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
|
||||
if btcEthPosValueRatio <= 0 {
|
||||
btcEthPosValueRatio = 5.0
|
||||
}
|
||||
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
|
||||
if altcoinPosValueRatio <= 0 {
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
|
||||
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
|
||||
// Position sizing guidance
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
|
||||
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
|
||||
|
||||
// 4. Trading frequency (editable)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
sb.WriteString("\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 period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
|
||||
}
|
||||
|
||||
// 5. Entry standards (editable)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. Decision process (editable)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
|
||||
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. Output format
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n")
|
||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
// Use the actual configured position value ratio for BTC/ETH in the example
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
|
||||
|
||||
// 8. Custom Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA indicators")
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - User Prompt
|
||||
// ============================================================================
|
||||
|
||||
// BuildUserPrompt builds User Prompt based on strategy configuration
|
||||
func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// System status
|
||||
sb.WriteString(fmt.Sprintf("Time: %s | Period: #%d | Runtime: %d minutes\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes))
|
||||
|
||||
// BTC market
|
||||
if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC {
|
||||
sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n",
|
||||
btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h,
|
||||
btcData.CurrentMACD, btcData.CurrentRSI7))
|
||||
}
|
||||
|
||||
// Account information
|
||||
sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %d\n\n",
|
||||
ctx.Account.TotalEquity,
|
||||
ctx.Account.AvailableBalance,
|
||||
(ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100,
|
||||
ctx.Account.TotalPnLPct,
|
||||
ctx.Account.MarginUsedPct,
|
||||
ctx.Account.PositionCount))
|
||||
|
||||
// Recently completed orders (placed before positions to ensure visibility)
|
||||
if len(ctx.RecentOrders) > 0 {
|
||||
sb.WriteString("## Recent Completed Trades\n")
|
||||
for i, order := range ctx.RecentOrders {
|
||||
resultStr := "Profit"
|
||||
if order.RealizedPnL < 0 {
|
||||
resultStr = "Loss"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s→%s (%s)\n",
|
||||
i+1, order.Symbol, order.Side,
|
||||
order.EntryPrice, order.ExitPrice,
|
||||
resultStr, order.RealizedPnL, order.PnLPct,
|
||||
order.EntryTime, order.ExitTime, order.HoldDuration))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Historical trading statistics (helps AI understand past performance)
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
// Get language from strategy config
|
||||
lang := e.GetLanguage()
|
||||
|
||||
// Win/Loss ratio
|
||||
var winLossRatio float64
|
||||
if ctx.TradingStats.AvgLoss > 0 {
|
||||
winLossRatio = ctx.TradingStats.AvgWin / ctx.TradingStats.AvgLoss
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
ctx.TradingStats.MaxDrawdownPct))
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("表现: 良好 - 保持当前策略\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
|
||||
} else {
|
||||
sb.WriteString("表现: 正常 - 有优化空间\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
ctx.TradingStats.MaxDrawdownPct))
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("Performance: GOOD - maintain current strategy\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
|
||||
} else {
|
||||
sb.WriteString("Performance: NORMAL - room for optimization\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Position information
|
||||
if len(ctx.Positions) > 0 {
|
||||
sb.WriteString("## Current Positions\n")
|
||||
for i, pos := range ctx.Positions {
|
||||
sb.WriteString(e.formatPositionInfo(i+1, pos, ctx))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("Current Positions: None\n\n")
|
||||
}
|
||||
|
||||
// Candidate coins (exclude coins already in positions to avoid duplicate data)
|
||||
positionSymbols := make(map[string]bool)
|
||||
for _, pos := range ctx.Positions {
|
||||
// Normalize symbol to handle both "ETH" and "ETHUSDT" formats
|
||||
normalizedSymbol := market.Normalize(pos.Symbol)
|
||||
positionSymbols[normalizedSymbol] = true
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap)))
|
||||
displayedCount := 0
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
// Skip if this coin is already a position (data already shown in positions section)
|
||||
normalizedCoinSymbol := market.Normalize(coin.Symbol)
|
||||
if positionSymbols[normalizedCoinSymbol] {
|
||||
continue
|
||||
}
|
||||
|
||||
marketData, hasData := ctx.MarketDataMap[coin.Symbol]
|
||||
if !hasData {
|
||||
continue
|
||||
}
|
||||
displayedCount++
|
||||
|
||||
sourceTags := e.formatCoinSourceTag(coin.Sources)
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags))
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
|
||||
if ctx.QuantDataMap != nil {
|
||||
if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Get language for market data formatting
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if e.GetLanguage() == LangChinese {
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
|
||||
// OI Ranking data (market-wide open interest changes)
|
||||
if ctx.OIRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// NetFlow Ranking data (market-wide fund flow)
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// Price Ranking data (market-wide gainers/losers)
|
||||
if ctx.PriceRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
|
||||
holdingDuration := ""
|
||||
if pos.UpdateTime > 0 {
|
||||
durationMs := time.Now().UnixMilli() - pos.UpdateTime
|
||||
durationMin := durationMs / (1000 * 60)
|
||||
if durationMin < 60 {
|
||||
holdingDuration = fmt.Sprintf(" | Holding Duration %d min", durationMin)
|
||||
} else {
|
||||
durationHour := durationMin / 60
|
||||
durationMinRemainder := durationMin % 60
|
||||
holdingDuration = fmt.Sprintf(" | Holding Duration %dh %dm", durationHour, durationMinRemainder)
|
||||
}
|
||||
}
|
||||
|
||||
positionValue := pos.Quantity * pos.MarkPrice
|
||||
if positionValue < 0 {
|
||||
positionValue = -positionValue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Position Value %.2f USDT | PnL%+.2f%% | PnL Amount%+.2f USDT | Peak PnL%.2f%% | Leverage %dx | Margin %.0f | Liq Price %.4f%s\n\n",
|
||||
index, pos.Symbol, strings.ToUpper(pos.Side),
|
||||
pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct,
|
||||
pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration))
|
||||
|
||||
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(e.formatMarketData(marketData))
|
||||
|
||||
if ctx.QuantDataMap != nil {
|
||||
if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
// 多信号源组合
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
hasHyperAll := false
|
||||
hasHyperMain := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
case "hyper_all":
|
||||
hasHyperAll = true
|
||||
case "hyper_main":
|
||||
hasHyperMain = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
}
|
||||
if hasAI500 && hasOILow {
|
||||
return " (AI500+OI_Low dual signal)"
|
||||
}
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
if hasHyperMain && hasAI500 {
|
||||
return " (HyperMain+AI500)"
|
||||
}
|
||||
if hasHyperAll || hasHyperMain {
|
||||
return " (Hyperliquid)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top 持仓增加)"
|
||||
case "oi_low":
|
||||
return " (OI_Low 持仓减少)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
case "hyper_all":
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Formatting
|
||||
// ============================================================================
|
||||
|
||||
func (e *StrategyEngine) formatMarketData(data *market.Data) string {
|
||||
var sb strings.Builder
|
||||
indicators := e.config.Indicators
|
||||
|
||||
// 明确标注币种
|
||||
sb.WriteString(fmt.Sprintf("=== %s Market Data ===\n\n", data.Symbol))
|
||||
sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice))
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if indicators.EnableOI || indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol))
|
||||
|
||||
if indicators.EnableOI && data.OpenInterest != nil {
|
||||
sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n",
|
||||
data.OpenInterest.Latest, data.OpenInterest.Average))
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate))
|
||||
}
|
||||
}
|
||||
|
||||
if len(data.TimeframeData) > 0 {
|
||||
timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"}
|
||||
for _, tf := range timeframeOrder {
|
||||
if tfData, ok := data.TimeframeData[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf)))
|
||||
e.formatTimeframeSeriesData(&sb, tfData, indicators)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Compatible with old data format
|
||||
if data.IntradaySeries != nil {
|
||||
klineConfig := indicators.Klines
|
||||
sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe))
|
||||
|
||||
if len(data.IntradaySeries.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices)))
|
||||
}
|
||||
|
||||
if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values)))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.IntradaySeries.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values)))
|
||||
}
|
||||
if len(data.IntradaySeries.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume)))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14))
|
||||
}
|
||||
}
|
||||
|
||||
if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe))
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n",
|
||||
data.LongerTermContext.EMA20, data.LongerTermContext.EMA50))
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n",
|
||||
data.LongerTermContext.ATR3, data.LongerTermContext.ATR14))
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n",
|
||||
data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume))
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) {
|
||||
if len(data.Klines) > 0 {
|
||||
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||||
for i, k := range data.Klines {
|
||||
t := time.Unix(k.Time/1000, 0).UTC()
|
||||
timeStr := t.Format("01-02 15:04")
|
||||
marker := ""
|
||||
if i == len(data.Klines)-1 {
|
||||
marker = " <- current"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%-14s %-9.4f %-9.4f %-9.4f %-9.4f %-12.2f%s\n",
|
||||
timeStr, k.Open, k.High, k.Low, k.Close, k.Volume, marker))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else if len(data.MidPrices) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices)))
|
||||
if indicators.EnableVolume && len(data.Volume) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
if len(data.EMA20Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA20: %s\n", formatFloatSlice(data.EMA20Values)))
|
||||
}
|
||||
if len(data.EMA50Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("EMA50: %s\n", formatFloatSlice(data.EMA50Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableMACD && len(data.MACDValues) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("MACD: %s\n", formatFloatSlice(data.MACDValues)))
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
if len(data.RSI7Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI7: %s\n", formatFloatSlice(data.RSI7Values)))
|
||||
}
|
||||
if len(data.RSI14Values) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("RSI14: %s\n", formatFloatSlice(data.RSI14Values)))
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableATR && data.ATR14 > 0 {
|
||||
sb.WriteString(fmt.Sprintf("ATR14: %.4f\n", data.ATR14))
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL && len(data.BOLLUpper) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("BOLL Upper: %s\n", formatFloatSlice(data.BOLLUpper)))
|
||||
sb.WriteString(fmt.Sprintf("BOLL Middle: %s\n", formatFloatSlice(data.BOLLMiddle)))
|
||||
sb.WriteString(fmt.Sprintf("BOLL Lower: %s\n", formatFloatSlice(data.BOLLLower)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatQuantData(data *QuantData) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 %s Quantitative Data:\n", data.Symbol))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("Price Change: ")
|
||||
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||
parts := []string{}
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.PriceChange[tf]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%s: %+.4f%%", tf, v*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString(strings.Join(parts, " | "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantNetflow && data.Netflow != nil {
|
||||
sb.WriteString("Fund Flow (Netflow):\n")
|
||||
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
|
||||
|
||||
if data.Netflow.Institution != nil {
|
||||
if data.Netflow.Institution.Future != nil && len(data.Netflow.Institution.Future) > 0 {
|
||||
sb.WriteString(" Institutional Futures:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Institution.Future[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Netflow.Institution.Spot != nil && len(data.Netflow.Institution.Spot) > 0 {
|
||||
sb.WriteString(" Institutional Spot:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Institution.Spot[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow.Personal != nil {
|
||||
if data.Netflow.Personal.Future != nil && len(data.Netflow.Personal.Future) > 0 {
|
||||
sb.WriteString(" Retail Futures:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Personal.Future[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.Netflow.Personal.Spot != nil && len(data.Netflow.Personal.Spot) > 0 {
|
||||
sb.WriteString(" Retail Spot:\n")
|
||||
for _, tf := range timeframes {
|
||||
if v, ok := data.Netflow.Personal.Spot[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", tf, formatFlowValue(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableQuantOI && len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if len(oiData.Delta) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Open Interest (%s):\n", exchange))
|
||||
for _, tf := range []string{"5m", "15m", "1h", "4h", "12h", "24h"} {
|
||||
if d, ok := oiData.Delta[tf]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" %s: %+.4f%% (%s)\n", tf, d.OIDeltaPercent, formatFlowValue(d.OIDeltaValue)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatFlowValue(v float64) string {
|
||||
sign := ""
|
||||
if v >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
|
||||
func formatFloatSlice(values []float64) string {
|
||||
strValues := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
strValues[i] = fmt.Sprintf("%.4f", v)
|
||||
}
|
||||
return "[" + strings.Join(strValues, ", ") + "]"
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDataDictionary 测试数据字典定义
|
||||
func TestDataDictionary(t *testing.T) {
|
||||
// 测试账户指标字典
|
||||
t.Run("AccountMetrics", func(t *testing.T) {
|
||||
equity := DataDictionary["AccountMetrics"]["Equity"]
|
||||
|
||||
if equity.NameZH != "总权益" {
|
||||
t.Errorf("Expected NameZH='总权益', got '%s'", equity.NameZH)
|
||||
}
|
||||
|
||||
if equity.NameEN != "Total Equity" {
|
||||
t.Errorf("Expected NameEN='Total Equity', got '%s'", equity.NameEN)
|
||||
}
|
||||
|
||||
if equity.Unit != "USDT" {
|
||||
t.Errorf("Expected Unit='USDT', got '%s'", equity.Unit)
|
||||
}
|
||||
|
||||
if equity.GetName(LangChinese) != "总权益" {
|
||||
t.Errorf("GetName(Chinese) failed")
|
||||
}
|
||||
|
||||
if equity.GetName(LangEnglish) != "Total Equity" {
|
||||
t.Errorf("GetName(English) failed")
|
||||
}
|
||||
})
|
||||
|
||||
// 测试持仓指标字典
|
||||
t.Run("PositionMetrics", func(t *testing.T) {
|
||||
peakPnL := DataDictionary["PositionMetrics"]["PeakPnL%"]
|
||||
|
||||
if peakPnL.NameZH == "" {
|
||||
t.Error("PeakPnL% NameZH is empty")
|
||||
}
|
||||
|
||||
if peakPnL.NameEN == "" {
|
||||
t.Error("PeakPnL% NameEN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(peakPnL.DescZH, "峰值") {
|
||||
t.Error("PeakPnL% DescZH should contain '峰值'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTradingRules 测试交易规则定义
|
||||
func TestTradingRules(t *testing.T) {
|
||||
t.Run("RiskManagement", func(t *testing.T) {
|
||||
maxMargin := TradingRules.RiskManagement["MaxMarginUsage"]
|
||||
|
||||
if maxMargin.Value != 0.30 {
|
||||
t.Errorf("Expected MaxMarginUsage=0.30, got %v", maxMargin.Value)
|
||||
}
|
||||
|
||||
if maxMargin.GetDesc(LangChinese) == "" {
|
||||
t.Error("MaxMarginUsage DescZH is empty")
|
||||
}
|
||||
|
||||
if maxMargin.GetDesc(LangEnglish) == "" {
|
||||
t.Error("MaxMarginUsage DescEN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(maxMargin.DescZH, "30%") {
|
||||
t.Error("MaxMarginUsage DescZH should mention 30%")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExitSignals", func(t *testing.T) {
|
||||
trailing := TradingRules.ExitSignals["TrailingStop"]
|
||||
|
||||
if trailing.Value != 0.30 {
|
||||
t.Errorf("Expected TrailingStop=0.30, got %v", trailing.Value)
|
||||
}
|
||||
|
||||
if !strings.Contains(trailing.ReasonZH, "止盈") {
|
||||
t.Error("TrailingStop ReasonZH should mention '止盈'")
|
||||
}
|
||||
|
||||
if !strings.Contains(trailing.ReasonEN, "profit") {
|
||||
t.Error("TrailingStop ReasonEN should mention 'profit'")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOIInterpretation 测试OI解读
|
||||
func TestOIInterpretation(t *testing.T) {
|
||||
t.Run("OI_Up_Price_Up", func(t *testing.T) {
|
||||
if OIInterpretation.OIUp_PriceUp.ZH == "" {
|
||||
t.Error("OI Up + Price Up ZH is empty")
|
||||
}
|
||||
|
||||
if OIInterpretation.OIUp_PriceUp.EN == "" {
|
||||
t.Error("OI Up + Price Up EN is empty")
|
||||
}
|
||||
|
||||
if !strings.Contains(OIInterpretation.OIUp_PriceUp.ZH, "多头") {
|
||||
t.Error("OI Up + Price Up should indicate bullish trend")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCommonMistakes 测试常见错误定义
|
||||
func TestCommonMistakes(t *testing.T) {
|
||||
if len(CommonMistakes) == 0 {
|
||||
t.Error("CommonMistakes should not be empty")
|
||||
}
|
||||
|
||||
for i, mistake := range CommonMistakes {
|
||||
if mistake.ErrorZH == "" {
|
||||
t.Errorf("Mistake #%d ErrorZH is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.ErrorEN == "" {
|
||||
t.Errorf("Mistake #%d ErrorEN is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.CorrectZH == "" {
|
||||
t.Errorf("Mistake #%d CorrectZH is empty", i+1)
|
||||
}
|
||||
|
||||
if mistake.CorrectEN == "" {
|
||||
t.Errorf("Mistake #%d CorrectEN is empty", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSchemaPrompt 测试Schema提示词生成
|
||||
func TestGetSchemaPrompt(t *testing.T) {
|
||||
t.Run("Chinese", func(t *testing.T) {
|
||||
prompt := GetSchemaPrompt(LangChinese)
|
||||
|
||||
if prompt == "" {
|
||||
t.Fatal("Chinese schema prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
mustContain := []string{
|
||||
"数据字典",
|
||||
"账户指标",
|
||||
"交易指标",
|
||||
"持仓指标",
|
||||
"市场数据",
|
||||
"持仓量(OI)变化解读",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
if !strings.Contains(prompt, keyword) {
|
||||
t.Errorf("Chinese prompt should contain '%s'", keyword)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("English", func(t *testing.T) {
|
||||
prompt := GetSchemaPrompt(LangEnglish)
|
||||
|
||||
if prompt == "" {
|
||||
t.Fatal("English schema prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
mustContain := []string{
|
||||
"Data Dictionary",
|
||||
"Account Metrics",
|
||||
"Trade Metrics",
|
||||
"Position Metrics",
|
||||
"Market Data",
|
||||
"Open Interest",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
if !strings.Contains(prompt, keyword) {
|
||||
t.Errorf("English prompt should contain '%s'", keyword)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Consistency", func(t *testing.T) {
|
||||
promptZH := GetSchemaPrompt(LangChinese)
|
||||
promptEN := GetSchemaPrompt(LangEnglish)
|
||||
|
||||
// 两个版本都应该包含相同数量的字段定义
|
||||
// 虽然内容不同,但结构应该相似
|
||||
|
||||
zhLines := strings.Split(promptZH, "\n")
|
||||
enLines := strings.Split(promptEN, "\n")
|
||||
|
||||
// 行数应该大致相当(允许10%的差异)
|
||||
ratio := float64(len(zhLines)) / float64(len(enLines))
|
||||
if ratio < 0.9 || ratio > 1.1 {
|
||||
t.Logf("Warning: Line count difference is significant (ZH: %d, EN: %d)",
|
||||
len(zhLines), len(enLines))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkGetSchemaPrompt 性能测试
|
||||
func BenchmarkGetSchemaPrompt(b *testing.B) {
|
||||
b.Run("Chinese", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GetSchemaPrompt(LangChinese)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("English", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = GetSchemaPrompt(LangEnglish)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFieldDefinitionMethods 测试字段定义方法
|
||||
func TestFieldDefinitionMethods(t *testing.T) {
|
||||
field := BilingualFieldDef{
|
||||
NameZH: "测试字段",
|
||||
NameEN: "Test Field",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "中文公式",
|
||||
FormulaEN: "English formula",
|
||||
DescZH: "中文描述",
|
||||
DescEN: "English description",
|
||||
}
|
||||
|
||||
// 测试GetName
|
||||
if field.GetName(LangChinese) != "测试字段" {
|
||||
t.Error("GetName(Chinese) failed")
|
||||
}
|
||||
if field.GetName(LangEnglish) != "Test Field" {
|
||||
t.Error("GetName(English) failed")
|
||||
}
|
||||
|
||||
// 测试GetFormula
|
||||
if field.GetFormula(LangChinese) != "中文公式" {
|
||||
t.Error("GetFormula(Chinese) failed")
|
||||
}
|
||||
if field.GetFormula(LangEnglish) != "English formula" {
|
||||
t.Error("GetFormula(English) failed")
|
||||
}
|
||||
|
||||
// 测试GetDesc
|
||||
if field.GetDesc(LangChinese) != "中文描述" {
|
||||
t.Error("GetDesc(Chinese) failed")
|
||||
}
|
||||
if field.GetDesc(LangEnglish) != "English description" {
|
||||
t.Error("GetDesc(English) failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleDefinitionMethods 测试规则定义方法
|
||||
func TestRuleDefinitionMethods(t *testing.T) {
|
||||
rule := BilingualRuleDef{
|
||||
Value: 0.30,
|
||||
DescZH: "中文描述",
|
||||
DescEN: "English description",
|
||||
ReasonZH: "中文原因",
|
||||
ReasonEN: "English reason",
|
||||
}
|
||||
|
||||
if rule.GetDesc(LangChinese) != "中文描述" {
|
||||
t.Error("GetDesc(Chinese) failed")
|
||||
}
|
||||
if rule.GetDesc(LangEnglish) != "English description" {
|
||||
t.Error("GetDesc(English) failed")
|
||||
}
|
||||
|
||||
if rule.GetReason(LangChinese) != "中文原因" {
|
||||
t.Error("GetReason(Chinese) failed")
|
||||
}
|
||||
if rule.GetReason(LangEnglish) != "English reason" {
|
||||
t.Error("GetReason(English) failed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user