From 5d1d0b6842cc479fab46512550e4ceab659534ee Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 6 Dec 2025 07:47:03 +0800 Subject: [PATCH] Feature/custom strategy (#1173) * feat: add Strategy Studio with multi-timeframe support - Add Strategy Studio page with three-column layout for strategy management - Support multi-timeframe K-line data selection (5m, 15m, 1h, 4h, etc.) - Add GetWithTimeframes() function in market package for fetching multiple timeframes - Add TimeframeSeriesData struct for storing per-timeframe technical indicators - Update formatMarketData() to display all selected timeframes in AI prompt - Add strategy API endpoints for CRUD operations and test run - Integrate real AI test runs with configured AI models - Support custom AI500 and OI Top API URLs from strategy config * docs: add Strategy Studio screenshot to README files * feat: add quant data integration and fix position click navigation - Integrate quant data API (netflow, OI, price changes) into Strategy Studio - Add enable_quant_data toggle in indicator editor - Fix position symbol click to navigate to chart (sync defaultSymbol prop) - Fix TradingView chart fullscreen flickering --- decision/engine.go | 1 + decision/strategy_engine.go | 205 ++++++++++++++++++ store/strategy.go | 5 + trader/auto_trader.go | 21 ++ web/src/components/ChartTabs.tsx | 23 +- web/src/components/TradingViewChart.tsx | 17 +- .../components/strategy/IndicatorEditor.tsx | 56 ++++- web/src/i18n/translations.ts | 2 + web/src/pages/TraderDashboard.tsx | 35 ++- web/src/types.ts | 3 + 10 files changed, 359 insertions(+), 9 deletions(-) diff --git a/decision/engine.go b/decision/engine.go index 5207aac7..2d73bab6 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -109,6 +109,7 @@ type Context struct { MarketDataMap map[string]*market.Data `json:"-"` // 不序列化,但内部使用 MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` // OI Top数据映射 + QuantDataMap map[string]*QuantData `json:"-"` // 量化数据映射(资金流向、持仓变化) BTCETHLeverage int `json:"-"` // BTC/ETH杠杆倍数(从配置读取) AltcoinLeverage int `json:"-"` // 山寨币杠杆倍数(从配置读取) } diff --git a/decision/strategy_engine.go b/decision/strategy_engine.go index 55c2da2c..cf29fd76 100644 --- a/decision/strategy_engine.go +++ b/decision/strategy_engine.go @@ -177,6 +177,193 @@ func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) { return externalData, nil } +// QuantData 量化数据结构(资金流向、持仓变化、价格变化) +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 获取单个币种的量化数据 +func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { + if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" { + return nil, nil + } + + // 替换 {symbol} 占位符 + url := strings.Replace(e.config.Indicators.QuantDataAPIURL, "{symbol}", symbol, -1) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP状态码: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取响应失败: %w", err) + } + + // 解析响应 + var apiResp struct { + Code int `json:"code"` + Data *QuantData `json:"data"` + } + + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("解析JSON失败: %w", err) + } + + if apiResp.Code != 0 { + return nil, fmt.Errorf("API返回错误码: %d", apiResp.Code) + } + + return apiResp.Data, nil +} + +// FetchQuantDataBatch 批量获取量化数据 +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("⚠️ 获取 %s 量化数据失败: %v", symbol, err) + continue + } + if data != nil { + result[symbol] = data + } + } + + return result +} + +// formatQuantData 格式化量化数据 +func (e *StrategyEngine) formatQuantData(data *QuantData) string { + if data == nil { + return "" + } + + var sb strings.Builder + sb.WriteString("📊 量化数据:\n") + + // 价格变化 + if len(data.PriceChange) > 0 { + sb.WriteString("价格变化: ") + timeframes := []string{"5m", "15m", "1h", "4h", "24h"} + parts := []string{} + for _, tf := range timeframes { + if v, ok := data.PriceChange[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, v)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + + // 资金流向 + if data.Netflow != nil { + sb.WriteString("资金流向(USDT):\n") + + // 机构资金 + if data.Netflow.Institution != nil { + if data.Netflow.Institution.Future != nil { + sb.WriteString(" 机构合约: ") + parts := []string{} + for _, tf := range []string{"1h", "4h", "24h"} { + if v, ok := data.Netflow.Institution.Future[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + if data.Netflow.Institution.Spot != nil { + sb.WriteString(" 机构现货: ") + parts := []string{} + for _, tf := range []string{"1h", "4h", "24h"} { + if v, ok := data.Netflow.Institution.Spot[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + } + + // 散户资金 + if data.Netflow.Personal != nil { + if data.Netflow.Personal.Future != nil { + sb.WriteString(" 散户合约: ") + parts := []string{} + for _, tf := range []string{"1h", "4h", "24h"} { + if v, ok := data.Netflow.Personal.Future[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.0f", tf, v)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + } + } + + // 持仓数据 + if len(data.OI) > 0 { + for exchange, oiData := range data.OI { + sb.WriteString(fmt.Sprintf("持仓(%s): 当前%.2f | 多%.2f 空%.2f\n", + exchange, oiData.CurrentOI, oiData.NetLong, oiData.NetShort)) + if len(oiData.Delta) > 0 { + sb.WriteString(" 持仓变化: ") + parts := []string{} + for _, tf := range []string{"1h", "4h", "24h"} { + if d, ok := oiData.Delta[tf]; ok { + parts = append(parts, fmt.Sprintf("%s: %+.2f%%", tf, d.OIDeltaPercent)) + } + } + sb.WriteString(strings.Join(parts, " | ")) + sb.WriteString("\n") + } + } + } + + return sb.String() +} + // fetchSingleExternalSource 获取单个外部数据源 func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) { client := &http.Client{ @@ -316,6 +503,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { 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") @@ -358,6 +552,13 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co // 使用策略配置的指标输出市场数据 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") } @@ -706,6 +907,10 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) { if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop { sb.WriteString("- AI500 / OI_Top 筛选标签(若有)\n") } + + if indicators.EnableQuantData { + sb.WriteString("- 量化数据(机构/散户资金流向、持仓变化、多周期价格变化)\n") + } } // GetRiskControlConfig 获取风险控制配置 diff --git a/store/strategy.go b/store/strategy.go index 39c56522..8c2d714b 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -91,6 +91,9 @@ type IndicatorConfig struct { ATRPeriods []int `json:"atr_periods,omitempty"` // 默认 [14] // 外部数据源 ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"` + // 量化数据源(资金流向、持仓变化、价格变化) + EnableQuantData bool `json:"enable_quant_data"` // 是否启用量化数据 + QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // 量化数据 API 地址 } // KlineConfig K线配置 @@ -211,6 +214,8 @@ func (s *StrategyStore) initDefaultData() error { EMAPeriods: []int{20, 50}, RSIPeriods: []int{7, 14}, ATRPeriods: []int{14}, + EnableQuantData: true, + QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c", }, RiskControl: RiskControlConfig{ MaxPositions: 3, diff --git a/trader/auto_trader.go b/trader/auto_trader.go index f78b1d7b..d8df9424 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -683,6 +683,27 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { } } + // 8. 获取量化数据(如果策略配置启用) + if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" { + // 收集需要查询的币种(候选币种 + 持仓币种) + symbolsToQuery := make(map[string]bool) + for _, coin := range candidateCoins { + symbolsToQuery[coin.Symbol] = true + } + for _, pos := range positionInfos { + symbolsToQuery[pos.Symbol] = true + } + + symbols := make([]string, 0, len(symbolsToQuery)) + for sym := range symbolsToQuery { + symbols = append(symbols, sym) + } + + logger.Infof("📊 [%s] 正在获取 %d 个币种的量化数据...", at.name, len(symbols)) + ctx.QuantDataMap = at.strategyEngine.FetchQuantDataBatch(symbols) + logger.Infof("📊 [%s] 成功获取 %d 个币种的量化数据", at.name, len(ctx.QuantDataMap)) + } + return ctx, nil } diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx index 29f8d98b..b1170937 100644 --- a/web/src/components/ChartTabs.tsx +++ b/web/src/components/ChartTabs.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { EquityChart } from './EquityChart' import { TradingViewChart } from './TradingViewChart' import { useLanguage } from '../contexts/LanguageContext' @@ -7,13 +7,25 @@ import { BarChart3, CandlestickChart } from 'lucide-react' interface ChartTabsProps { traderId: string + selectedSymbol?: string // 从外部选择的币种 + updateKey?: number // 强制更新的 key } type ChartTab = 'equity' | 'kline' -export function ChartTabs({ traderId }: ChartTabsProps) { +export function ChartTabs({ traderId, selectedSymbol, updateKey }: ChartTabsProps) { const { language } = useLanguage() const [activeTab, setActiveTab] = useState('equity') + const [chartSymbol, setChartSymbol] = useState('BTCUSDT') + + // 当从外部选择币种时,自动切换到K线图 + useEffect(() => { + if (selectedSymbol) { + console.log('[ChartTabs] 收到币种选择:', selectedSymbol, 'updateKey:', updateKey) + setChartSymbol(selectedSymbol) + setActiveTab('kline') + } + }, [selectedSymbol, updateKey]) console.log('[ChartTabs] rendering, activeTab:', activeTab) @@ -81,7 +93,12 @@ export function ChartTabs({ traderId }: ChartTabsProps) { {activeTab === 'equity' ? ( ) : ( - + )} diff --git a/web/src/components/TradingViewChart.tsx b/web/src/components/TradingViewChart.tsx index cec02aec..23e3cc00 100644 --- a/web/src/components/TradingViewChart.tsx +++ b/web/src/components/TradingViewChart.tsx @@ -66,6 +66,14 @@ function TradingViewChartComponent({ const [showSymbolDropdown, setShowSymbolDropdown] = useState(false) const [isFullscreen, setIsFullscreen] = useState(false) + // 当外部传入的 defaultSymbol 变化时,更新内部 symbol + useEffect(() => { + if (defaultSymbol && defaultSymbol !== symbol) { + console.log('[TradingViewChart] 更新币种:', defaultSymbol) + setSymbol(defaultSymbol) + } + }, [defaultSymbol]) + // 获取完整的交易对符号 (合约格式: BINANCE:BTCUSDT.P) const getFullSymbol = () => { const exchangeInfo = EXCHANGES.find((e) => e.id === exchange) @@ -102,7 +110,8 @@ function TradingViewChartComponent({ script.type = 'text/javascript' script.async = true script.innerHTML = JSON.stringify({ - autosize: true, + width: '100%', + height: '100%', symbol: getFullSymbol(), interval: timeInterval, timezone: 'Etc/UTC', @@ -147,9 +156,10 @@ function TradingViewChartComponent({
{/* Header */}
diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index a63b98ab..f7952fae 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,4 +1,4 @@ -import { Clock, Activity } from 'lucide-react' +import { Clock, Activity, Database } from 'lucide-react' import type { IndicatorConfig } from '../../types' interface IndicatorEditorProps { @@ -51,6 +51,9 @@ export function IndicatorEditor({ intraday: { zh: '日内', en: 'Intraday' }, swing: { zh: '波段', en: 'Swing' }, position: { zh: '趋势', en: 'Position' }, + quantData: { zh: '量化数据', en: 'Quant Data' }, + quantDataDesc: { zh: '资金流向、持仓变化、价格变化(按币种查询)', en: 'Netflow, OI delta, price change (per coin)' }, + quantDataUrl: { zh: '量化数据 API', en: 'Quant Data API' }, } return translations[key]?.[language] || key } @@ -257,6 +260,57 @@ export function IndicatorEditor({ ))}
+ + {/* Quant Data Source */} +
+
+ + {t('quantData')} +
+

{t('quantDataDesc')}

+ +
+ {/* Enable Toggle */} +
+
+
+ {t('quantData')} +
+ + !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) + } + disabled={disabled} + className="w-4 h-4 rounded accent-green-500" + /> +
+ + {/* API URL */} + {config.enable_quant_data && ( +
+ + + !disabled && onChange({ ...config, quant_data_api_url: e.target.value }) + } + disabled={disabled} + placeholder="http://example.com/api/coin/{symbol}?include=netflow,oi,price" + className="w-full px-2 py-1.5 rounded text-xs font-mono" + style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} + /> +
+ )} +
+
) } diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index d7bf0534..922872c9 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -86,6 +86,7 @@ export const translations = { // TradingView Chart marketChart: 'Market Chart', + viewChart: 'Click to view chart', enterSymbol: 'Enter symbol...', popularSymbols: 'Popular Symbols', fullscreen: 'Fullscreen', @@ -1083,6 +1084,7 @@ export const translations = { // TradingView Chart marketChart: '行情图表', + viewChart: '点击查看图表', enterSymbol: '输入币种...', popularSymbols: '热门币种', fullscreen: '全屏', diff --git a/web/src/pages/TraderDashboard.tsx b/web/src/pages/TraderDashboard.tsx index 83d5a187..f2a458cf 100644 --- a/web/src/pages/TraderDashboard.tsx +++ b/web/src/pages/TraderDashboard.tsx @@ -59,6 +59,16 @@ export default function TraderDashboard() { return saved ? parseInt(saved, 10) : 5 }) + // 选中的币种(用于图表显示) + const [selectedChartSymbol, setSelectedChartSymbol] = useState() + const [chartUpdateKey, setChartUpdateKey] = useState(0) + + // 点击持仓币种时调用 + const handlePositionSymbolClick = (symbol: string) => { + setSelectedChartSymbol(symbol) + setChartUpdateKey(prev => prev + 1) // 强制触发更新 + } + // 当 limit 变化时保存到 localStorage const handleLimitChange = (newLimit: number) => { setDecisionLimit(newLimit) @@ -420,7 +430,11 @@ export default function TraderDashboard() {
{/* Chart Tabs (Equity / K-line) */}
- +
{/* Current Positions */} @@ -490,7 +504,24 @@ export default function TraderDashboard() { className="border-b border-gray-800 last:border-0" > - {pos.symbol} +