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
This commit is contained in:
tinkle-community
2025-12-06 07:47:03 +08:00
committed by GitHub
parent 5cff32e4f2
commit 5d1d0b6842
10 changed files with 359 additions and 9 deletions

View File

@@ -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:"-"` // 山寨币杠杆倍数(从配置读取)
}

View File

@@ -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 获取风险控制配置

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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<ChartTab>('equity')
const [chartSymbol, setChartSymbol] = useState<string>('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' ? (
<EquityChart traderId={traderId} embedded />
) : (
<TradingViewChart height={400} embedded />
<TradingViewChart
height={400}
embedded
defaultSymbol={chartSymbol}
key={chartSymbol} // 强制重新渲染当币种变化时
/>
)}
</div>
</div>

View File

@@ -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({
<div
className={`${embedded ? '' : 'binance-card'} overflow-hidden ${embedded ? '' : 'animate-fade-in'} ${
isFullscreen
? 'fixed inset-0 z-50 rounded-none'
? 'fixed inset-0 z-50 rounded-none flex flex-col'
: ''
}`}
style={isFullscreen ? { background: '#0B0E11' } : undefined}
>
{/* Header */}
<div
@@ -354,8 +364,9 @@ function TradingViewChartComponent({
<div
ref={containerRef}
style={{
height: isFullscreen ? 'calc(100vh - 60px)' : height,
height: isFullscreen ? 'calc(100vh - 65px)' : height,
background: '#0B0E11',
overflow: 'hidden',
}}
/>

View File

@@ -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({
))}
</div>
</div>
{/* Quant Data Source */}
<div>
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4" style={{ color: '#22c55e' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
</div>
<p className="text-xs mb-3" style={{ color: '#848E9C' }}>{t('quantDataDesc')}</p>
<div
className="p-3 rounded-lg space-y-3"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} />
<span className="text-xs" style={{ color: '#EAECEF' }}>{t('quantData')}</span>
</div>
<input
type="checkbox"
checked={config.enable_quant_data || false}
onChange={(e) =>
!disabled && onChange({ ...config, enable_quant_data: e.target.checked })
}
disabled={disabled}
className="w-4 h-4 rounded accent-green-500"
/>
</div>
{/* API URL */}
{config.enable_quant_data && (
<div>
<label className="text-[10px] mb-1 block" style={{ color: '#848E9C' }}>
{t('quantDataUrl')} <span style={{ color: '#5E6673' }}>({'{symbol}'} = )</span>
</label>
<input
type="text"
value={config.quant_data_api_url || ''}
onChange={(e) =>
!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' }}
/>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -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: '全屏',

View File

@@ -59,6 +59,16 @@ export default function TraderDashboard() {
return saved ? parseInt(saved, 10) : 5
})
// 选中的币种(用于图表显示)
const [selectedChartSymbol, setSelectedChartSymbol] = useState<string | undefined>()
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() {
<div className="space-y-6">
{/* Chart Tabs (Equity / K-line) */}
<div className="animate-slide-in" style={{ animationDelay: '0.1s' }}>
<ChartTabs traderId={selectedTrader.trader_id} />
<ChartTabs
traderId={selectedTrader.trader_id}
selectedSymbol={selectedChartSymbol}
updateKey={chartUpdateKey}
/>
</div>
{/* Current Positions */}
@@ -490,7 +504,24 @@ export default function TraderDashboard() {
className="border-b border-gray-800 last:border-0"
>
<td className="py-3 font-mono font-semibold">
{pos.symbol}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
console.log('点击了币种:', pos.symbol)
handlePositionSymbolClick(pos.symbol)
}}
className="hover:underline cursor-pointer px-2 py-1 rounded transition-all"
style={{
color: '#F0B90B',
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.3)'
}}
title={t('viewChart', language)}
>
📈 {pos.symbol}
</button>
</td>
<td className="py-3">
<span

View File

@@ -403,6 +403,9 @@ export interface IndicatorConfig {
rsi_periods?: number[];
atr_periods?: number[];
external_data_sources?: ExternalDataSource[];
// 量化数据源(资金流向、持仓变化、价格变化)
enable_quant_data?: boolean;
quant_data_api_url?: string;
}
export interface KlineConfig {