mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 20:41:14 +08:00
243 lines
7.1 KiB
Go
243 lines
7.1 KiB
Go
package agent
|
||
|
||
import (
|
||
"encoding/json"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"nofx/store"
|
||
)
|
||
|
||
const strategyCreateDraftIntentField = "strategy_draft"
|
||
|
||
var compactCoinPairRE = regexp.MustCompile(`(?i)\b([A-Z0-9]{2,10})\s*(?:和|与|/|,|,|、|\+)\s*([A-Z0-9]{2,10})\b`)
|
||
|
||
type strategyDraft struct {
|
||
Name string `json:"name,omitempty"`
|
||
StrategyKind string `json:"strategy_kind,omitempty"`
|
||
CoinSourceIntent string `json:"coin_source_intent,omitempty"`
|
||
Symbols []string `json:"symbols,omitempty"`
|
||
Timeframe string `json:"timeframe,omitempty"`
|
||
Leverage int `json:"leverage,omitempty"`
|
||
}
|
||
|
||
func normalizeStrategyDraft(d strategyDraft) strategyDraft {
|
||
d.Name = strings.TrimSpace(d.Name)
|
||
d.StrategyKind = strings.TrimSpace(d.StrategyKind)
|
||
d.CoinSourceIntent = strings.TrimSpace(d.CoinSourceIntent)
|
||
d.Timeframe = strings.ToLower(strings.TrimSpace(d.Timeframe))
|
||
if d.Leverage < 0 {
|
||
d.Leverage = 0
|
||
}
|
||
if len(d.Symbols) > 0 {
|
||
normalized := make([]string, 0, len(d.Symbols))
|
||
for _, symbol := range d.Symbols {
|
||
symbol = normalizeCoinSymbol(symbol)
|
||
if symbol != "" {
|
||
normalized = append(normalized, symbol)
|
||
}
|
||
}
|
||
d.Symbols = cleanStringList(normalized)
|
||
}
|
||
if len(d.Symbols) > 0 && d.CoinSourceIntent == "" {
|
||
d.CoinSourceIntent = "static"
|
||
}
|
||
if d.CoinSourceIntent == "static" && len(d.Symbols) == 0 {
|
||
d.CoinSourceIntent = ""
|
||
}
|
||
return d
|
||
}
|
||
|
||
func marshalStrategyDraft(d strategyDraft) string {
|
||
d = normalizeStrategyDraft(d)
|
||
raw, err := json.Marshal(d)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
func unmarshalStrategyDraft(raw string) strategyDraft {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return strategyDraft{}
|
||
}
|
||
var d strategyDraft
|
||
if err := json.Unmarshal([]byte(raw), &d); err != nil {
|
||
return strategyDraft{}
|
||
}
|
||
return normalizeStrategyDraft(d)
|
||
}
|
||
|
||
func buildStrategyDraftFromActiveSession(session ActiveSkillSession) strategyDraft {
|
||
d := strategyDraft{}
|
||
if value, ok := session.CollectedFields[strategyCreateDraftIntentField]; ok {
|
||
d = unmarshalStrategyDraft(activeFieldString(value))
|
||
}
|
||
if value, ok := session.CollectedFields["name"]; ok {
|
||
d.Name = activeFieldString(value)
|
||
}
|
||
d = applyStrategyDraftText(d, session.Goal)
|
||
for i, msg := range session.LocalHistory {
|
||
if msg.Role != "user" {
|
||
continue
|
||
}
|
||
d = applyStrategyDraftText(d, msg.Content)
|
||
if d.Name == "" && i > 0 && activeHistoryMessageAsksStrategyName(session.LocalHistory[i-1].Content) {
|
||
d.Name = inferStandaloneStrategyName(msg.Content)
|
||
}
|
||
}
|
||
return normalizeStrategyDraft(d)
|
||
}
|
||
|
||
func applyStrategyDraftText(d strategyDraft, text string) strategyDraft {
|
||
text = strings.TrimSpace(text)
|
||
if text == "" {
|
||
return normalizeStrategyDraft(d)
|
||
}
|
||
lower := strings.ToLower(text)
|
||
if containsAny(lower, []string{"趋势", "trend"}) {
|
||
d.StrategyKind = "trend"
|
||
}
|
||
if d.Name == "" {
|
||
if value := extractDelimitedSegmentAfterKeywords(text, []string{"取名为", "取名叫", "命名为", "名称叫", "名字叫", "名为", "叫做", "取名", "名称", "名字是", "called"}); value != "" {
|
||
d.Name = value
|
||
}
|
||
}
|
||
if containsAny(lower, []string{"ai500"}) {
|
||
d.CoinSourceIntent = "ai500"
|
||
}
|
||
if symbols := extractStrategyDraftSymbols(text); len(symbols) > 0 {
|
||
d.Symbols = symbols
|
||
d.CoinSourceIntent = "static"
|
||
}
|
||
if timeframes := extractTimeframes(text); len(timeframes) > 0 {
|
||
d.Timeframe = timeframes[0]
|
||
}
|
||
if leverage, ok := extractLabeledInt(text, []string{"杠杆", "leverage"}); ok && leverage > 0 {
|
||
d.Leverage = leverage
|
||
} else if leverage := extractCompactLeverage(text); leverage > 0 {
|
||
d.Leverage = leverage
|
||
}
|
||
return normalizeStrategyDraft(d)
|
||
}
|
||
|
||
func activeHistoryMessageAsksStrategyName(text string) bool {
|
||
lower := strings.ToLower(strings.TrimSpace(text))
|
||
return containsAny(lower, []string{"策略名", "名称", "名字", "叫什么", "name"})
|
||
}
|
||
|
||
func inferStandaloneStrategyName(text string) string {
|
||
value := strings.TrimSpace(text)
|
||
if value == "" || len([]rune(value)) > 50 {
|
||
return ""
|
||
}
|
||
if strategyCreateConfirmationReply(value) || strategyCreateDefaultConfigReply(value) || isCancelSkillReply(value) {
|
||
return ""
|
||
}
|
||
if parseStrategyTypeValue(value) != "" {
|
||
return ""
|
||
}
|
||
if containsAny(strings.ToLower(value), []string{"创建", "grid_trading", "ai_trading"}) {
|
||
return ""
|
||
}
|
||
return value
|
||
}
|
||
|
||
func extractStrategyDraftSymbols(text string) []string {
|
||
upper := strings.ToUpper(text)
|
||
candidates := []string{
|
||
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
|
||
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
|
||
"NEAR", "ATOM", "MATIC", "INJ", "AAVE", "UNI", "LDO", "MKR", "CRV",
|
||
}
|
||
found := make([]string, 0, 4)
|
||
for _, match := range compactCoinPairRE.FindAllStringSubmatch(upper, -1) {
|
||
if len(match) >= 3 {
|
||
found = append(found, match[1], match[2])
|
||
}
|
||
}
|
||
for _, symbol := range candidates {
|
||
if strings.Contains(upper, symbol+"USDT") || strings.Contains(upper, symbol+"USD") || strings.Contains(upper, symbol) {
|
||
found = append(found, symbol)
|
||
}
|
||
}
|
||
return cleanStringList(symbolsToUSDT(found))
|
||
}
|
||
|
||
func symbolsToUSDT(symbols []string) []string {
|
||
out := make([]string, 0, len(symbols))
|
||
for _, symbol := range symbols {
|
||
symbol = normalizeCoinSymbol(symbol)
|
||
if symbol != "" {
|
||
out = append(out, symbol)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func extractCompactLeverage(text string) int {
|
||
lower := strings.ToLower(text)
|
||
for _, marker := range []string{"x", "倍"} {
|
||
idx := strings.Index(lower, marker)
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
prefix := lower[:idx]
|
||
matches := firstIntegerPattern.FindAllString(prefix, -1)
|
||
if len(matches) == 0 {
|
||
continue
|
||
}
|
||
value, err := strconv.Atoi(matches[len(matches)-1])
|
||
if err == nil {
|
||
return value
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func applyStrategyDraftToConfig(cfg *store.StrategyConfig, draft strategyDraft) []string {
|
||
if cfg == nil {
|
||
return nil
|
||
}
|
||
draft = normalizeStrategyDraft(draft)
|
||
changed := make([]string, 0, 4)
|
||
if draft.StrategyKind != "" {
|
||
cfg.StrategyType = "ai_trading"
|
||
changed = append(changed, "strategy_kind")
|
||
}
|
||
switch draft.CoinSourceIntent {
|
||
case "static":
|
||
if len(draft.Symbols) > 0 {
|
||
cfg.CoinSource.SourceType = "static"
|
||
cfg.CoinSource.StaticCoins = append([]string(nil), draft.Symbols...)
|
||
cfg.CoinSource.UseAI500 = false
|
||
cfg.CoinSource.UseOITop = false
|
||
cfg.CoinSource.UseOILow = false
|
||
changed = append(changed, "symbols")
|
||
}
|
||
case "ai500":
|
||
cfg.CoinSource.SourceType = "ai500"
|
||
cfg.CoinSource.UseAI500 = true
|
||
if cfg.CoinSource.AI500Limit <= 0 {
|
||
cfg.CoinSource.AI500Limit = 3
|
||
}
|
||
changed = append(changed, "coin_source")
|
||
}
|
||
if draft.Timeframe != "" {
|
||
cfg.Indicators.Klines.PrimaryTimeframe = draft.Timeframe
|
||
if len(cfg.Indicators.Klines.SelectedTimeframes) == 0 {
|
||
cfg.Indicators.Klines.SelectedTimeframes = []string{draft.Timeframe}
|
||
} else if !containsString(cfg.Indicators.Klines.SelectedTimeframes, draft.Timeframe) {
|
||
cfg.Indicators.Klines.SelectedTimeframes = append([]string{draft.Timeframe}, cfg.Indicators.Klines.SelectedTimeframes...)
|
||
}
|
||
changed = append(changed, "timeframe")
|
||
}
|
||
if draft.Leverage > 0 {
|
||
cfg.RiskControl.BTCETHMaxLeverage = draft.Leverage
|
||
cfg.RiskControl.AltcoinMaxLeverage = draft.Leverage
|
||
changed = append(changed, "leverage")
|
||
}
|
||
return cleanStringList(changed)
|
||
}
|