Files
nofx/decision/strategy_engine.go
T
tinkle-community 9fa2432705 feat: improve strategy studio and fix trader deletion bug
- Add strategy export/import functionality to Strategy Studio
- Fix trader deletion not removing from memory (competition page ghost data)
- Simplify TraderConfigViewModal: remove unused fields, show strategy name
- Improve quant data formatting: OI/Netflow multi-timeframe display
- Add configurable OI/Netflow toggles in indicator settings
- Clean up unused frontend components and dead code
2025-12-09 16:46:58 +08:00

970 lines
32 KiB
Go

package decision
import (
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"nofx/market"
"nofx/pool"
"nofx/store"
"strings"
"time"
)
// StrategyEngine strategy execution engine
// Responsible for dynamically fetching data and assembling prompts based on strategy configuration
type StrategyEngine struct {
config *store.StrategyConfig
}
// NewStrategyEngine creates strategy execution engine
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
return &StrategyEngine{config: config}
}
// GetCandidateCoins gets candidate coins based on strategy configuration
func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
var candidates []CandidateCoin
symbolSources := make(map[string][]string)
coinSource := e.config.CoinSource
// Set custom API URL (if configured)
if coinSource.CoinPoolAPIURL != "" {
pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
logger.Infof("✓ Using strategy-configured AI500 API URL: %s", coinSource.CoinPoolAPIURL)
}
if coinSource.OITopAPIURL != "" {
pool.SetOITopAPI(coinSource.OITopAPIURL)
logger.Infof("✓ Using strategy-configured OI Top API URL: %s", coinSource.OITopAPIURL)
}
switch coinSource.SourceType {
case "static":
// Static coin list
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return candidates, nil
case "coinpool":
// Use AI500 coin pool only
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
case "oi_top":
// Use OI Top only
return e.getOITopCoins(coinSource.OITopLimit)
case "mixed":
// Mixed mode: AI500 + OI Top
if coinSource.UseCoinPool {
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
if err != nil {
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err)
} else {
for _, coin := range poolCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
}
}
}
if coinSource.UseOITop {
oiCoins, err := e.getOITopCoins(coinSource.OITopLimit)
if err != nil {
logger.Infof("⚠️ Failed to get OI Top: %v", err)
} else {
for _, coin := range oiCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top")
}
}
}
// Add static coins (if any)
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
if _, exists := symbolSources[symbol]; !exists {
symbolSources[symbol] = []string{"static"}
} else {
symbolSources[symbol] = append(symbolSources[symbol], "static")
}
}
// Convert to candidate coin list
for symbol, sources := range symbolSources {
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: sources,
})
}
return candidates, nil
default:
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType)
}
}
// getCoinPoolCoins gets AI500 coin pool
func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 30
}
symbols, err := pool.GetTopRatedCoins(limit)
if err != nil {
return nil, err
}
var candidates []CandidateCoin
for _, symbol := range symbols {
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"ai500"},
})
}
return candidates, nil
}
// getOITopCoins gets OI Top coins
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
}
positions, err := pool.GetOITopPositions()
if err != nil {
return nil, err
}
var candidates []CandidateCoin
for i, pos := range positions {
if i >= limit {
break
}
symbol := market.Normalize(pos.Symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"oi_top"},
})
}
return candidates, nil
}
// FetchMarketData fetches market data based on strategy configuration
func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) {
// Currently using existing market.Get, can be customized based on strategy configuration later
return market.Get(symbol)
}
// FetchExternalData fetches external data sources
func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) {
externalData := make(map[string]interface{})
for _, source := range e.config.Indicators.ExternalDataSources {
data, err := e.fetchSingleExternalSource(source)
if err != nil {
logger.Infof("⚠️ Failed to fetch external data source [%s]: %v", source.Name, err)
continue
}
externalData[source.Name] = data
}
return externalData, nil
}
// QuantData quantitative data structure (fund flow, position changes, price changes)
type QuantData struct {
Symbol string `json:"symbol"`
Price float64 `json:"price"`
Netflow *NetflowData `json:"netflow,omitempty"`
OI map[string]*OIData `json:"oi,omitempty"`
PriceChange map[string]float64 `json:"price_change,omitempty"`
}
type NetflowData struct {
Institution *FlowTypeData `json:"institution,omitempty"`
Personal *FlowTypeData `json:"personal,omitempty"`
}
type FlowTypeData struct {
Future map[string]float64 `json:"future,omitempty"`
Spot map[string]float64 `json:"spot,omitempty"`
}
type OIData struct {
CurrentOI float64 `json:"current_oi"`
NetLong float64 `json:"net_long"`
NetShort float64 `json:"net_short"`
Delta map[string]*OIDeltaData `json:"delta,omitempty"`
}
type OIDeltaData struct {
OIDelta float64 `json:"oi_delta"`
OIDeltaValue float64 `json:"oi_delta_value"`
OIDeltaPercent float64 `json:"oi_delta_percent"`
}
// FetchQuantData fetches quantitative data for a single coin
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
return nil, nil
}
// Check if URL contains {symbol} placeholder
apiURL := e.config.Indicators.QuantDataAPIURL
if !strings.Contains(apiURL, "{symbol}") {
logger.Infof("⚠️ Quant data URL does not contain {symbol} placeholder, data may be incorrect for %s", symbol)
}
// Replace {symbol} placeholder
url := strings.Replace(apiURL, "{symbol}", symbol, -1)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Parse response
var apiResp struct {
Code int `json:"code"`
Data *QuantData `json:"data"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
if apiResp.Code != 0 {
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code)
}
return apiResp.Data, nil
}
// FetchQuantDataBatch batch fetches quantitative data
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
result := make(map[string]*QuantData)
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
return result
}
for _, symbol := range symbols {
data, err := e.FetchQuantData(symbol)
if err != nil {
logger.Infof("⚠️ Failed to fetch quantitative data for %s: %v", symbol, err)
continue
}
if data != nil {
result[symbol] = data
}
}
return result
}
// formatQuantData formats quantitative data
func (e *StrategyEngine) formatQuantData(data *QuantData) string {
if data == nil {
return ""
}
indicators := e.config.Indicators
// If both OI and Netflow are disabled, return empty
if !indicators.EnableQuantOI && !indicators.EnableQuantNetflow {
return ""
}
var sb strings.Builder
sb.WriteString("📊 Quantitative Data:\n")
// Price changes (API returns decimals, multiply by 100 for percentage)
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")
}
// Fund flow (Netflow) - only show if enabled
if indicators.EnableQuantNetflow && data.Netflow != nil {
sb.WriteString("Fund Flow (Netflow):\n")
timeframes := []string{"5m", "15m", "1h", "4h", "12h", "24h"}
// Institutional funds
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)))
}
}
}
}
// Retail funds
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)))
}
}
}
}
}
// Open Interest (OI) - only show if enabled
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()
}
// fetchSingleExternalSource fetches a single external data source
func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) {
client := &http.Client{
Timeout: time.Duration(source.RefreshSecs) * time.Second,
}
if client.Timeout == 0 {
client.Timeout = 30 * time.Second
}
req, err := http.NewRequest(source.Method, source.URL, nil)
if err != nil {
return nil, err
}
// Add request headers
for k, v := range source.Headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
// If data path is specified, extract data at specified path
if source.DataPath != "" {
result = extractJSONPath(result, source.DataPath)
}
return result, nil
}
// extractJSONPath extracts JSON path data (simple implementation)
func extractJSONPath(data interface{}, path string) interface{} {
parts := strings.Split(path, ".")
current := data
for _, part := range parts {
if m, ok := current.(map[string]interface{}); ok {
current = m[part]
} else {
return nil
}
}
return current
}
// 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 configured)
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))
// 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")
}
// Trading statistics
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
sb.WriteString("## Historical Trading Statistics\n")
sb.WriteString(fmt.Sprintf("Total Trades: %d | Win Rate: %.1f%% | Profit Factor: %.2f | Sharpe Ratio: %.2f\n",
ctx.TradingStats.TotalTrades,
ctx.TradingStats.WinRate,
ctx.TradingStats.ProfitFactor,
ctx.TradingStats.SharpeRatio))
sb.WriteString(fmt.Sprintf("Total P&L: %.2f USDT | Avg Win: %.2f | Avg Loss: %.2f | Max Drawdown: %.1f%%\n\n",
ctx.TradingStats.TotalPnL,
ctx.TradingStats.AvgWin,
ctx.TradingStats.AvgLoss,
ctx.TradingStats.MaxDrawdownPct))
}
// Recently completed orders
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\n",
i+1, order.Symbol, order.Side,
order.EntryPrice, order.ExitPrice,
resultStr, order.RealizedPnL, order.PnLPct,
order.FilledAt))
}
sb.WriteString("\n")
}
// Candidate coins
sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap)))
displayedCount := 0
for _, coin := range ctx.CandidateCoins {
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))
// Add quantitative data if available
if ctx.QuantDataMap != nil {
if quantData, hasQuant := ctx.QuantDataMap[coin.Symbol]; hasQuant {
sb.WriteString(e.formatQuantData(quantData))
}
}
sb.WriteString("\n")
}
sb.WriteString("\n")
sb.WriteString("---\n\n")
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
return sb.String()
}
// formatPositionInfo formats position information
func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string {
var sb strings.Builder
// Calculate holding duration
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)
}
}
// Calculate position value
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))
// Output market data using strategy configured indicators
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
sb.WriteString(e.formatMarketData(marketData))
// Add quantitative data if available
if ctx.QuantDataMap != nil {
if quantData, hasQuant := ctx.QuantDataMap[pos.Symbol]; hasQuant {
sb.WriteString(e.formatQuantData(quantData))
}
}
sb.WriteString("\n")
}
return sb.String()
}
// formatCoinSourceTag formats coin source tag
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
return " (AI500+OI_Top dual signal)"
} else if len(sources) == 1 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top position growth)"
case "static":
return " (Manual selection)"
}
}
return ""
}
// formatMarketData formats market data according to strategy configuration
func (e *StrategyEngine) formatMarketData(data *market.Data) string {
var sb strings.Builder
indicators := e.config.Indicators
// Current price (always display)
sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice))
// EMA
if indicators.EnableEMA {
sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20))
}
// MACD
if indicators.EnableMACD {
sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD))
}
// RSI
if indicators.EnableRSI {
sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7))
}
sb.WriteString("\n\n")
// OI and Funding Rate
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))
}
}
// Prefer using multi-timeframe data (new addition)
if len(data.TimeframeData) > 0 {
// Output in timeframe order
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
// Intraday data
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))
}
}
// Longer-term data
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()
}
// formatTimeframeSeriesData formats series data for a single timeframe
func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) {
// Use OHLCV table format if kline data is available
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 {
// Fallback to old format for backward compatibility
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)))
}
}
// Technical indicators (only show if enabled and data available)
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))
}
sb.WriteString("\n")
}
// formatFlowValue formats flow value with M/K units
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)
}
// formatFloatSlice formats float slice
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, ", ") + "]"
}
// 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
// 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) - from strategy config (non-editable, auto-generated)
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
sb.WriteString(fmt.Sprintf("1. Risk-Reward Ratio: Must be ≥ 1:%.1f\n", riskControl.MinRiskRewardRatio))
sb.WriteString(fmt.Sprintf("2. Max Positions: %d coins (quality > quantity)\n", riskControl.MaxPositions))
sb.WriteString(fmt.Sprintf("3. Single Coin Position: Altcoins %.0f-%.0f U | BTC/ETH %.0f-%.0f U\n",
accountEquity*0.8, accountEquity*riskControl.MaxPositionRatio,
accountEquity*5, accountEquity*10))
sb.WriteString(fmt.Sprintf("4. Leverage Limits: **Altcoins max %dx leverage** | **BTC/ETH max %dx leverage**\n",
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
sb.WriteString(fmt.Sprintf("5. Margin Usage ≤ %.0f%%\n", riskControl.MaxMarginUsage*100))
sb.WriteString(fmt.Sprintf("6. Opening Amount: Recommended ≥%.0f USDT\n", riskControl.MinPositionSize))
sb.WriteString(fmt.Sprintf("7. Minimum Confidence: ≥%d\n\n", riskControl.MinConfidence))
// 4. Trading frequency and signal quality (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 tips (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")
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, accountEquity*5))
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()
}
// writeAvailableIndicators writes list of available indicators
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.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.UseCoinPool || 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")
}
}
// GetRiskControlConfig gets risk control configuration
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
return e.config.RiskControl
}
// GetConfig gets complete strategy configuration
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
return e.config
}