From 5bdffee3b0fcba5d04397d1885f03b32a9456507 Mon Sep 17 00:00:00 2001 From: tinklefund Date: Mon, 25 May 2026 01:25:05 +0800 Subject: [PATCH] feat(strategy): support Hyperliquid stock strategy editing - Extend strategy storage and engine analysis for Hyperliquid defaults - Rework coin source and indicator editors for the stock strategy flow - Update Strategy Studio translations and page wiring --- kernel/engine.go | 126 ++- kernel/engine_analysis.go | 16 + store/strategy.go | 141 ++- store/strategy_hyperliquid_defaults_test.go | 42 + .../components/strategy/CoinSourceEditor.tsx | 733 ++++++++-------- .../components/strategy/IndicatorEditor.tsx | 804 ++++-------------- web/src/i18n/strategy-translations.ts | 8 + web/src/pages/StrategyStudioPage.tsx | 147 ++-- 8 files changed, 903 insertions(+), 1114 deletions(-) create mode 100644 store/strategy_hyperliquid_defaults_test.go diff --git a/kernel/engine.go b/kernel/engine.go index a5e1f0ce..cfed0a23 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -6,13 +6,14 @@ import ( "fmt" "io" "net/http" - "os" "nofx/logger" "nofx/market" "nofx/provider/hyperliquid" "nofx/provider/nofxos" "nofx/security" "nofx/store" + "os" + "sort" "strings" "time" ) @@ -224,6 +225,22 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string) } } +func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool { + if e == nil || e.config == nil { + return false + } + source := e.config.CoinSource + if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.UseHyperAll || source.UseHyperMain { + return true + } + for _, symbol := range source.StaticCoins { + if market.IsXyzDexAsset(symbol) { + return true + } + } + return false +} + // GetRiskControlConfig gets risk control configuration func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig { return e.config.RiskControl @@ -368,6 +385,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { } return e.filterExcludedCoins(coins), nil + case "hyper_rank": + coins, err := e.getHyperRankCoins(coinSource.HyperRankCategory, coinSource.HyperRankDirection, coinSource.HyperRankLimit) + if err != nil { + return nil, err + } + return e.filterExcludedCoins(coins), nil + case "mixed": if coinSource.UseAI500 { poolCoins, err := e.getAI500Coins(coinSource.AI500Limit) @@ -586,6 +610,90 @@ func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) { return candidates, nil } +func clampHyperRankLimit(limit int) int { + if limit <= 0 { + return 5 + } + if limit > 10 { + return 10 + } + return limit +} + +func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int) ([]CandidateCoin, error) { + category = strings.ToLower(strings.TrimSpace(category)) + if category == "" { + category = "stock" + } + direction = strings.ToLower(strings.TrimSpace(direction)) + if direction == "" { + direction = "gainers" + } + limit = clampHyperRankLimit(limit) + + ctx := context.Background() + var ranked []struct { + symbol string + info hyperliquid.CoinInfo + cat string + } + + if category == "crypto" || category == "all" { + coins, err := hyperliquid.GetPerpDexCoins(ctx, "") + if err != nil { + return nil, fmt.Errorf("failed to get Hyperliquid crypto ranking: %w", err) + } + for _, coin := range coins { + ranked = append(ranked, struct { + symbol string + info hyperliquid.CoinInfo + cat string + }{symbol: market.Normalize(coin.Symbol + "USDT"), info: coin, cat: "crypto"}) + } + } + + if category != "crypto" { + coins, err := hyperliquid.GetPerpDexCoins(ctx, "xyz") + if err != nil { + return nil, fmt.Errorf("failed to get Hyperliquid XYZ ranking: %w", err) + } + for _, coin := range coins { + base := strings.TrimPrefix(coin.Symbol, "xyz:") + cat := hyperliquid.XYZCategory(base) + if category != "all" && cat != category { + continue + } + ranked = append(ranked, struct { + symbol string + info hyperliquid.CoinInfo + cat string + }{symbol: hyperliquid.FormatCoinForAPI("xyz:" + base), info: coin, cat: cat}) + } + } + + sort.SliceStable(ranked, func(i, j int) bool { + switch direction { + case "losers": + return ranked[i].info.Change24hPct < ranked[j].info.Change24hPct + case "volume": + return ranked[i].info.Volume24h > ranked[j].info.Volume24h + default: + return ranked[i].info.Change24hPct > ranked[j].info.Change24hPct + } + }) + + if len(ranked) > limit { + ranked = ranked[:limit] + } + candidates := make([]CandidateCoin, 0, len(ranked)) + source := fmt.Sprintf("hyper_rank_%s_%s", category, direction) + for _, item := range ranked { + candidates = append(candidates, CandidateCoin{Symbol: item.symbol, Sources: []string{source}}) + } + logger.Infof("✅ Loaded %d Hyperliquid rank coins (%s/%s, capped at %d)", len(candidates), category, direction, limit) + return candidates, nil +} + // ============================================================================ // External & Quant Data // ============================================================================ @@ -677,6 +785,10 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) { if !e.config.Indicators.EnableQuantData { return nil, nil } + if e.usesHyperliquidNativeUniverse() || market.IsXyzDexAsset(symbol) { + logger.Infof("⏭️ Skipping NofxOS quant data for Hyperliquid symbol %s; using native Hyperliquid klines/mark data only", symbol) + return nil, nil + } // Use nofxos client with unified API key include := "oi,price" @@ -773,6 +885,10 @@ func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData { if !indicators.EnableOIRanking { return nil } + if e.usesHyperliquidNativeUniverse() { + logger.Infof("⏭️ Skipping NofxOS OI ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth") + return nil + } duration := indicators.OIRankingDuration if duration == "" { @@ -804,6 +920,10 @@ func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData { if !indicators.EnableNetFlowRanking { return nil } + if e.usesHyperliquidNativeUniverse() { + logger.Infof("⏭️ Skipping NofxOS netflow ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth") + return nil + } duration := indicators.NetFlowRankingDuration if duration == "" { @@ -836,6 +956,10 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData { if !indicators.EnablePriceRanking { return nil } + if e.usesHyperliquidNativeUniverse() { + logger.Infof("⏭️ Skipping NofxOS price ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth") + return nil + } durations := indicators.PriceRankingDuration if durations == "" { diff --git a/kernel/engine_analysis.go b/kernel/engine_analysis.go index 8c08ba0b..125949bd 100644 --- a/kernel/engine_analysis.go +++ b/kernel/engine_analysis.go @@ -84,6 +84,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S return nil, fmt.Errorf("failed to fetch market data: %w", err) } } + pruneCandidateCoinsWithoutMarketData(ctx) // Ensure OITopDataMap is initialized if ctx.OITopDataMap == nil { @@ -223,6 +224,21 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { return nil } +func pruneCandidateCoinsWithoutMarketData(ctx *Context) { + if ctx == nil || len(ctx.CandidateCoins) == 0 || len(ctx.MarketDataMap) == 0 { + return + } + kept := make([]CandidateCoin, 0, len(ctx.CandidateCoins)) + for _, coin := range ctx.CandidateCoins { + if _, ok := ctx.MarketDataMap[coin.Symbol]; ok { + kept = append(kept, coin) + continue + } + logger.Infof("⚠️ Skipping candidate %s in AI prompt: no valid market/K-line data", coin.Symbol) + } + ctx.CandidateCoins = kept +} + // ============================================================================ // AI Response Parsing // ============================================================================ diff --git a/store/strategy.go b/store/strategy.go index 4380446a..68aa8b9f 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -146,6 +146,8 @@ func (c *StrategyConfig) NormalizeProductSchema() { c.CoinSource.UseAI500 = true c.CoinSource.UseOITop = false c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false if c.CoinSource.AI500Limit <= 0 { c.CoinSource.AI500Limit = 3 } @@ -153,6 +155,8 @@ func (c *StrategyConfig) NormalizeProductSchema() { c.CoinSource.UseAI500 = false c.CoinSource.UseOITop = true c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false if c.CoinSource.OITopLimit <= 0 { c.CoinSource.OITopLimit = 3 } @@ -160,6 +164,8 @@ func (c *StrategyConfig) NormalizeProductSchema() { c.CoinSource.UseAI500 = false c.CoinSource.UseOITop = false c.CoinSource.UseOILow = true + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false if c.CoinSource.OILowLimit <= 0 { c.CoinSource.OILowLimit = 3 } @@ -167,11 +173,53 @@ func (c *StrategyConfig) NormalizeProductSchema() { c.CoinSource.UseAI500 = false c.CoinSource.UseOITop = false c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + case "hyper_all": + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = true + c.CoinSource.UseHyperMain = false + case "hyper_main": + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = true + if c.CoinSource.HyperMainLimit <= 0 { + c.CoinSource.HyperMainLimit = 30 + } + case "hyper_rank": + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + if c.CoinSource.HyperRankCategory == "" { + c.CoinSource.HyperRankCategory = "stock" + } + if c.CoinSource.HyperRankDirection == "" { + c.CoinSource.HyperRankDirection = "gainers" + } + if c.CoinSource.HyperRankLimit <= 0 { + c.CoinSource.HyperRankLimit = 5 + } default: - c.CoinSource.SourceType = "ai500" - c.CoinSource.UseAI500 = true - if c.CoinSource.AI500Limit <= 0 { - c.CoinSource.AI500Limit = 3 + c.CoinSource.SourceType = "hyper_rank" + c.CoinSource.UseAI500 = false + c.CoinSource.UseOITop = false + c.CoinSource.UseOILow = false + c.CoinSource.UseHyperAll = false + c.CoinSource.UseHyperMain = false + if c.CoinSource.HyperRankCategory == "" { + c.CoinSource.HyperRankCategory = "stock" + } + if c.CoinSource.HyperRankDirection == "" { + c.CoinSource.HyperRankDirection = "gainers" + } + if c.CoinSource.HyperRankLimit <= 0 { + c.CoinSource.HyperRankLimit = 5 } } @@ -209,6 +257,12 @@ func normalizeCoinSourceType(value string) string { return "oi_top" case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"): return "oi_low" + case strings.Contains(compact, "hyperrank") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "动态榜单") || strings.Contains(value, "涨幅榜"): + return "hyper_rank" + case strings.Contains(compact, "hyperall"): + return "hyper_all" + case strings.Contains(compact, "hypermain"): + return "hyper_main" case strings.Contains(value, "static") || strings.Contains(value, "固定") || strings.Contains(value, "静态"): return "static" default: @@ -226,8 +280,14 @@ func inferCoinSourceType(source CoinSourceConfig) string { return "oi_top" case source.UseOILow: return "oi_low" + case source.UseHyperAll: + return "hyper_all" + case source.UseHyperMain: + return "hyper_main" + case source.HyperRankCategory != "" || source.HyperRankDirection != "" || source.HyperRankLimit > 0: + return "hyper_rank" default: - return "ai500" + return "hyper_rank" } } @@ -717,6 +777,12 @@ type CoinSourceConfig struct { UseHyperMain bool `json:"use_hyper_main"` // Hyperliquid Main maximum count (default 20) HyperMainLimit int `json:"hyper_main_limit,omitempty"` + // Hyperliquid dynamic ranking category: stock, commodity, index, forex, pre_ipo, crypto, all + HyperRankCategory string `json:"hyper_rank_category,omitempty"` + // Hyperliquid dynamic ranking direction: gainers, losers, volume + HyperRankDirection string `json:"hyper_rank_direction,omitempty"` + // Hyperliquid dynamic ranking maximum count. Defaults to 5 and is hard capped at 10 for AI context safety. + HyperRankLimit int `json:"hyper_rank_limit,omitempty"` // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig } @@ -850,13 +916,19 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { config := StrategyConfig{ Language: normalizedLang, CoinSource: CoinSourceConfig{ - SourceType: "ai500", - UseAI500: true, - AI500Limit: 3, - UseOITop: false, - OITopLimit: 3, - UseOILow: false, - OILowLimit: 3, + SourceType: "hyper_rank", + UseAI500: false, + AI500Limit: 3, + UseOITop: false, + OITopLimit: 3, + UseOILow: false, + OILowLimit: 3, + UseHyperAll: false, + UseHyperMain: false, + HyperMainLimit: 30, + HyperRankCategory: "stock", + HyperRankDirection: "gainers", + HyperRankLimit: 5, }, Indicators: IndicatorConfig{ Klines: KlineConfig{ @@ -880,24 +952,21 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { RSIPeriods: []int{7, 14}, ATRPeriods: []int{14}, BOLLPeriods: []int{20}, - // NofxOS unified API key - NofxOSAPIKey: "cm_568c67eae410d912c54c", - // Quant data - EnableQuantData: true, - EnableQuantOI: true, - EnableQuantNetflow: true, - // OI ranking data - EnableOIRanking: true, - OIRankingDuration: "1h", - OIRankingLimit: 10, - // NetFlow ranking data - EnableNetFlowRanking: true, + // Hyperliquid strategies must use native Hyperliquid market data by default. + // NofxOS datasets do not cover all Hyperliquid XYZ assets, so keep them off. + NofxOSAPIKey: "", + EnableQuantData: false, + EnableQuantOI: false, + EnableQuantNetflow: false, + EnableOIRanking: false, + OIRankingDuration: "1h", + OIRankingLimit: 10, + EnableNetFlowRanking: false, NetFlowRankingDuration: "1h", NetFlowRankingLimit: 10, - // Price ranking data - EnablePriceRanking: true, - PriceRankingDuration: "1h,4h,24h", - PriceRankingLimit: 10, + EnablePriceRanking: false, + PriceRankingDuration: "1h,4h,24h", + PriceRankingLimit: 10, }, RiskControl: RiskControlConfig{ MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED) @@ -914,9 +983,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { if lang == "zh" { config.PromptSections = PromptSectionsConfig{ - RoleDefinition: `# 你是一个专业的加密货币交易AI + RoleDefinition: `# 你是一个专业的 Hyperliquid USDC 多资产交易AI -你的任务是根据提供的市场数据做出交易决策。你是一个经验丰富的量化交易员,擅长技术分析和风险管理。`, +你的任务是根据提供的市场数据做出交易决策。你可以分析并交易 Hyperliquid 上线的 USDC 永续合约,包括美股、大宗商品和加密资产。你是一个经验丰富的量化交易员,擅长跨资产技术分析和风险管理。`, TradingFrequency: `# ⏱️ 交易频率意识 - 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔 @@ -934,9 +1003,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig { } } else { config.PromptSections = PromptSectionsConfig{ - RoleDefinition: `# You are a professional cryptocurrency trading AI + RoleDefinition: `# You are a professional Hyperliquid USDC multi-asset trading AI -Your task is to make trading decisions based on the provided market data. You are an experienced quantitative trader skilled in technical analysis and risk management.`, +Your task is to make trading decisions based on the provided market data. You can analyze and trade Hyperliquid-listed USDC perpetual markets, including US equities, commodities and crypto assets. You are an experienced quantitative trader skilled in cross-asset technical analysis and risk management.`, TradingFrequency: `# ⏱️ Trading Frequency Awareness - Excellent trader: 2-4 trades per day ≈ 0.1-0.2 trades per hour @@ -1390,8 +1459,14 @@ func (c *StrategyConfig) getEffectiveCoinCount() int { count = c.CoinSource.OITopLimit case "oi_low": count = c.CoinSource.OILowLimit + case "hyper_rank": + count = c.CoinSource.HyperRankLimit + case "hyper_main": + count = c.CoinSource.HyperMainLimit + case "hyper_all": + count = c.CoinSource.HyperMainLimit default: - count = c.CoinSource.AI500Limit + count = c.CoinSource.HyperRankLimit } if count <= 0 { count = 3 diff --git a/store/strategy_hyperliquid_defaults_test.go b/store/strategy_hyperliquid_defaults_test.go new file mode 100644 index 00000000..108ebdcb --- /dev/null +++ b/store/strategy_hyperliquid_defaults_test.go @@ -0,0 +1,42 @@ +package store + +import "testing" + +func TestDefaultHyperliquidStrategyDoesNotEnableNofxOSData(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + assertHyperliquidStockRankDefault(t, cfg) + ind := cfg.Indicators + if ind.NofxOSAPIKey != "" { + t.Fatalf("default should not include a NofxOS API key for Hyperliquid strategies") + } + if ind.EnableQuantData || ind.EnableQuantOI || ind.EnableQuantNetflow || ind.EnableOIRanking || ind.EnableNetFlowRanking || ind.EnablePriceRanking { + t.Fatalf("default Hyperliquid strategy must not enable NofxOS datasets: %+v", ind) + } + if !ind.EnableRawKlines { + t.Fatalf("raw Hyperliquid klines must stay enabled") + } +} + +func TestHyperliquidRankDefaultSurvivesClampAndNormalize(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + cfg.CoinSource.UseAI500 = true + cfg.ClampLimits() + assertHyperliquidStockRankDefault(t, cfg) + if cfg.CoinSource.UseAI500 { + t.Fatalf("Hyperliquid rank strategy must clear stale AI500 flag: %+v", cfg.CoinSource) + } +} + +func TestEmptyCoinSourceInfersHyperliquidRankNotAI500(t *testing.T) { + cfg := GetDefaultStrategyConfig("zh") + cfg.CoinSource = CoinSourceConfig{} + cfg.NormalizeProductSchema() + assertHyperliquidStockRankDefault(t, cfg) +} + +func assertHyperliquidStockRankDefault(t *testing.T, cfg StrategyConfig) { + t.Helper() + if cfg.CoinSource.SourceType != "hyper_rank" || cfg.CoinSource.HyperRankCategory != "stock" || cfg.CoinSource.HyperRankDirection != "gainers" || cfg.CoinSource.HyperRankLimit != 5 { + t.Fatalf("coin source = %+v, want Hyperliquid dynamic stock gainers top 5", cfg.CoinSource) + } +} diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 1c3c7a72..5c58e0f8 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' -import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { BarChart3, Check, Globe2, Search, Star, X } from 'lucide-react' import type { CoinSourceConfig } from '../../types' -import { coinSource, ts } from '../../i18n/strategy-translations' -import { NofxSelect } from '../ui/select' + +const API_BASE = import.meta.env.VITE_API_BASE || '' interface CoinSourceEditorProps { config: CoinSourceConfig @@ -11,423 +11,402 @@ interface CoinSourceEditorProps { language: string } -export function CoinSourceEditor({ - config, - onChange, - disabled, - language, -}: CoinSourceEditorProps) { - const [newCoin, setNewCoin] = useState('') - const [newExcludedCoin, setNewExcludedCoin] = useState('') +interface MarketSymbol { + symbol: string + display?: string + name?: string + category?: string + mark_price?: number + volume_24h?: number + change_24h_pct?: number +} - const sourceTypes = [ - { value: 'static', icon: List, color: '#848E9C' }, - { value: 'ai500', icon: Database, color: '#F0B90B' }, - { value: 'oi_top', icon: TrendingUp, color: '#0ECB81' }, - { value: 'oi_low', icon: TrendingDown, color: '#F6465D' }, - ] as const +const t = (language: string, zh: string, en: string) => (language === 'zh' ? zh : en) - // xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix - const xyzDexAssets = new Set([ - // Stocks - 'TSLA', 'NVDA', 'AAPL', 'MSFT', 'META', 'AMZN', 'GOOGL', 'AMD', 'COIN', 'NFLX', - 'PLTR', 'HOOD', 'INTC', 'MSTR', 'TSM', 'ORCL', 'MU', 'RIVN', 'COST', 'LLY', - 'CRCL', 'SKHX', 'SNDK', - // Forex - 'EUR', 'JPY', - // Commodities - 'GOLD', 'SILVER', - // Index - 'XYZ100', - ]) +const categoryLabels: Record = { + stock: 'Stocks', + commodity: 'Commodities', + index: 'Indices', + forex: 'FX', + pre_ipo: 'Pre-IPO', + crypto: 'Crypto', +} - const isXyzDexAsset = (symbol: string): boolean => { - const base = symbol.toUpperCase().replace(/^XYZ:/, '').replace(/USDT$|USD$|-USDC$/, '') - return xyzDexAssets.has(base) - } +const categoryOrder = ['stock', 'commodity', 'index', 'forex', 'pre_ipo', 'crypto'] - const MAX_STATIC_COINS = 10 +const rankDirections = [ + { value: 'gainers', labelZh: '涨幅榜', labelEn: 'Gainers' }, + { value: 'losers', labelZh: '跌幅榜', labelEn: 'Losers' }, + { value: 'volume', labelZh: '成交额榜', labelEn: 'Volume' }, +] as const - const showToast = (msg: string) => { - const toast = document.createElement('div') - toast.textContent = msg - toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' - toast.style.cssText = 'background:#F6465D;color:#fff;' - document.body.appendChild(toast) - setTimeout(() => toast.remove(), 2000) - } +const SELECTED_MARKET_LIMIT = 10 +const RANK_LIMIT = 10 +const DEFAULT_RANK_LIMIT = 5 +const CATALOG_DISPLAY_LIMIT = 120 - const handleAddCoin = () => { - if (!newCoin.trim()) return +function formatCompactNumber(value?: number) { + if (!value || Number.isNaN(value)) return '—' + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B` + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K` + return `$${value.toFixed(0)}` +} - const currentCoins = config.static_coins || [] - if (currentCoins.length >= MAX_STATIC_COINS) { - showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`) - return +function displaySymbol(symbol?: MarketSymbol) { + return symbol?.display || symbol?.symbol || '' +} + +export function CoinSourceEditor({ config, onChange, disabled, language }: CoinSourceEditorProps) { + const [symbols, setSymbols] = useState([]) + const [loadingSymbols, setLoadingSymbols] = useState(false) + const [symbolError, setSymbolError] = useState(null) + const [query, setQuery] = useState('') + const [category, setCategory] = useState('all') + + useEffect(() => { + let cancelled = false + const loadSymbols = async () => { + setLoadingSymbols(true) + setSymbolError(null) + try { + const res = await fetch(`${API_BASE}/api/symbols?exchange=hyperliquid-xyz`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + const rows: MarketSymbol[] = data.symbols || [] + if (!cancelled) setSymbols(rows) + } catch (err) { + if (!cancelled) setSymbolError(err instanceof Error ? err.message : 'Failed to load symbols') + } finally { + if (!cancelled) setLoadingSymbols(false) + } } - - const symbol = newCoin.toUpperCase().trim() - - // For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT - let formattedSymbol: string - if (isXyzDexAsset(symbol)) { - // Remove xyz: prefix (case-insensitive) and any USD suffixes - const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '') - formattedSymbol = `xyz:${base}` - } else { - formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` + loadSymbols() + return () => { + cancelled = true } + }, []) - if (!currentCoins.includes(formattedSymbol)) { - onChange({ - ...config, - static_coins: [...currentCoins, formattedSymbol], - }) - } - setNewCoin('') - } - - const handleRemoveCoin = (coin: string) => { - onChange({ - ...config, - static_coins: (config.static_coins || []).filter((c) => c !== coin), - }) - } - - const handleAddExcludedCoin = () => { - if (!newExcludedCoin.trim()) return - const symbol = newExcludedCoin.toUpperCase().trim() - - // For xyz dex assets, use xyz: prefix without USDT - let formattedSymbol: string - if (isXyzDexAsset(symbol)) { - const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '') - formattedSymbol = `xyz:${base}` - } else { - formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` - } - - const currentExcluded = config.excluded_coins || [] - if (!currentExcluded.includes(formattedSymbol)) { - onChange({ - ...config, - excluded_coins: [...currentExcluded, formattedSymbol], - }) - } - setNewExcludedCoin('') - } - - const handleRemoveExcludedCoin = (coin: string) => { - onChange({ - ...config, - excluded_coins: (config.excluded_coins || []).filter((c) => c !== coin), - }) - } - - // NofxOS badge component - const NofxOSBadge = () => ( - - NofxOS - + const selectedCoins = config.static_coins || [] + const selectedSet = useMemo(() => new Set(selectedCoins), [selectedCoins]) + const selectedMarketSymbols = useMemo( + () => selectedCoins.map((coin) => symbols.find((s) => s.symbol === coin) || { symbol: coin, display: coin }), + [selectedCoins, symbols] ) + const filteredSymbols = useMemo(() => { + const q = query.trim().toLowerCase() + return symbols + .filter((symbol) => category === 'all' || (symbol.category || 'crypto') === category) + .filter((symbol) => { + if (!q) return true + return [symbol.symbol, symbol.display, symbol.name, symbol.category] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)) + }) + .slice(0, CATALOG_DISPLAY_LIMIT) + }, [symbols, query, category]) + + const rankedPreview = useMemo(() => { + const rankCategory = config.hyper_rank_category || 'stock' + const rankDirection = config.hyper_rank_direction || 'gainers' + const rankLimit = Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT) + const filtered = symbols.filter((symbol) => rankCategory === 'all' || (symbol.category || 'crypto') === rankCategory) + const sorted = [...filtered].sort((a, b) => { + if (rankDirection === 'losers') return (a.change_24h_pct || 0) - (b.change_24h_pct || 0) + if (rankDirection === 'volume') return (b.volume_24h || 0) - (a.volume_24h || 0) + return (b.change_24h_pct || 0) - (a.change_24h_pct || 0) + }) + return sorted.slice(0, rankLimit) + }, [symbols, config.hyper_rank_category, config.hyper_rank_direction, config.hyper_rank_limit]) + + const chooseSource = (sourceType: CoinSourceConfig['source_type']) => { + if (disabled) return + onChange({ + ...config, + source_type: sourceType, + use_ai500: false, + use_oi_top: false, + use_oi_low: false, + use_hyper_all: false, + use_hyper_main: false, + hyper_rank_category: config.hyper_rank_category || 'stock', + hyper_rank_direction: config.hyper_rank_direction || 'gainers', + hyper_rank_limit: Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT), + }) + } + + const updateRank = (patch: Partial) => { + if (disabled) return + onChange({ + ...config, + source_type: 'hyper_rank', + use_ai500: false, + use_oi_top: false, + use_oi_low: false, + use_hyper_all: false, + use_hyper_main: false, + hyper_rank_category: config.hyper_rank_category || 'stock', + hyper_rank_direction: config.hyper_rank_direction || 'gainers', + hyper_rank_limit: Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT), + ...patch, + }) + } + + const addSymbol = (symbol: MarketSymbol) => { + if (disabled || selectedSet.has(symbol.symbol) || selectedCoins.length >= SELECTED_MARKET_LIMIT) return + onChange({ + ...config, + source_type: 'static', + use_ai500: false, + use_oi_top: false, + use_oi_low: false, + use_hyper_all: false, + use_hyper_main: false, + static_coins: [...selectedCoins, symbol.symbol], + }) + } + + const removeSymbol = (symbol: string) => { + if (disabled) return + onChange({ + ...config, + static_coins: selectedCoins.filter((coin) => coin !== symbol), + }) + } + return ( -
- {/* Source Type Selector */} -
- -
- {sourceTypes.map(({ value, icon: Icon, color }) => ( +
+
+
+
+
+ +

+ {t(language, 'Hyperliquid 原生标的', 'Native Hyperliquid universe')} +

+
+

+ {t( + language, + '只使用 Hyperliquid 实时 Universe / K 线 / 标记价格;不混入外部聚合数据。', + 'Uses Hyperliquid live universe, candles and mark prices only; no external aggregate datasets are mixed in.' + )} +

+
+ + {symbols.length || '—'} {t(language, '个可视化标的', 'visual markets')} + +
+
+ +
+ {[ + { + value: 'hyper_rank' as const, + icon: BarChart3, + title: t(language, '动态榜单', 'Dynamic ranking'), + desc: t(language, '美股/大宗/指数/FX/Crypto 的涨幅榜、跌幅榜、成交额榜;默认 Top 5,最多 Top 10', 'Gainers, losers and volume rankings by asset class; default Top 5, max Top 10'), + }, + { + value: 'static' as const, + icon: Star, + title: t(language, '自选单标的/组合', 'Selected market(s)'), + desc: t(language, '从下方卡片点选 1-10 个固定标的', 'Pick 1-10 fixed markets from visual cards below'), + }, + ].map(({ value, icon: Icon, title, desc }) => { + const active = config.source_type === value + return ( - ))} -
+ ) + })}
- {/* Static Coins - only for static mode */} - {config.source_type === 'static' && ( -
- -
- {(config.static_coins || []).map((coin) => ( - +
+
+
{t(language, '榜单规则', 'Ranking rule')}
+
{t(language, '动态选出当前榜单前 N 个;默认 Top 5,最多 Top 10。下方仍显示全量可见标的,可手动改成自选。', 'Select current top N dynamically; default Top 5, max Top 10. The full visible market catalog remains below for manual selection.')}
+
+ +
+ +
+ {[...categoryOrder, 'all'].map((cat) => ( + - )} - + {cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat} + ))}
- {!disabled && ( -
- setNewCoin(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()} - placeholder="BTC, ETH, SOL..." - className="flex-1 px-4 py-2 rounded-lg bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + +
+ {rankDirections.map((item) => ( -
- )} + ))} +
+ +
+ {rankedPreview.map((symbol, index) => ( +
+
#{index + 1}
+
{displaySymbol(symbol)}
+
+ Vol {formatCompactNumber(symbol.volume_24h)} + {typeof symbol.change_24h_pct === 'number' && ( + = 0 ? 'text-nofx-success' : 'text-nofx-danger'}> + {symbol.change_24h_pct >= 0 ? '+' : ''}{symbol.change_24h_pct.toFixed(2)}% + + )} +
+
+ ))} +
)} - {/* Excluded Coins */} -
-
- - -
-

- {ts(coinSource.excludedCoinsDesc, language)} -

-
- {(config.excluded_coins || []).map((coin) => ( - - {coin} - {!disabled && ( - +
+
+
+ {t(language, '自选标的', 'Selected markets')} + {selectedCoins.length}/{SELECTED_MARKET_LIMIT} +
+
+ {selectedMarketSymbols.length > 0 ? selectedMarketSymbols.map((symbol) => ( + + {displaySymbol(symbol)} + {!disabled && ( + + )} + + )) : ( + + {t(language, '点击下方标的卡片添加。', 'Click market cards below to add.')} + )} - - ))} - {(config.excluded_coins || []).length === 0 && ( - - {ts(coinSource.excludedNone, language)} - - )} -
- {!disabled && ( -
- setNewExcludedCoin(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddExcludedCoin()} - placeholder="BTC, ETH, DOGE..." - className="flex-1 px-4 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> - -
- )} -
- - {/* AI500 Options - only for ai500 mode */} - {config.source_type === 'ai500' && ( -
-
-
- - - AI500 {ts(coinSource.dataSourceConfig, language)} - -
-
- - - {config.use_ai500 && ( -
- - {ts(coinSource.ai500Limit, language)}: - - - !disabled && - onChange({ ...config, ai500_limit: parseInt(val) || 3 }) - } +
+
+
+ + ({ value: n, label: String(n) }))} - className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" + onChange={(e) => setQuery(e.target.value)} + placeholder={t(language, '搜索 SAMSUNG / TESLA / GOLD…', 'Search SAMSUNG / TESLA / GOLD…')} + className="w-full rounded-xl border border-white/10 bg-nofx-bg py-2 pl-9 pr-3 text-sm text-nofx-text outline-none focus:border-sky-400/50" />
- )} - -

- {ts(coinSource.nofxosNote, language)} -

-
-
- )} - - {/* OI Top Options - only for oi_top mode */} - {config.source_type === 'oi_top' && ( -
-
-
- - - {ts(coinSource.oiIncreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)} - - +
+ {['all', ...categoryOrder].map((cat) => ( + + ))} +
-
-
- + {loadingSymbols &&
{t(language, '加载 Hyperliquid 标的中…', 'Loading Hyperliquid markets…')}
} + {symbolError &&
{symbolError}
} - {config.use_oi_top && ( -
- - {ts(coinSource.oiTopLimit, language)}: - - - !disabled && - onChange({ ...config, oi_top_limit: parseInt(val) || 3 }) - } - disabled={disabled} - options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} - className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> + {!loadingSymbols && !symbolError && ( +
+ {filteredSymbols.map((symbol) => { + const selected = selectedSet.has(symbol.symbol) + const change = symbol.change_24h_pct + return ( + + ) + })}
)} - -

- {ts(coinSource.nofxosNote, language)} -

- )} - - {/* OI Low Options - only for oi_low mode */} - {config.source_type === 'oi_low' && ( -
-
-
- - - {ts(coinSource.oiDecreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)} - - -
-
- -
- - - {config.use_oi_low && ( -
- - {ts(coinSource.oiLowLimit, language)}: - - - !disabled && - onChange({ ...config, oi_low_limit: parseInt(val) || 3 }) - } - disabled={disabled} - options={[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => ({ value: n, label: String(n) }))} - className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text" - /> -
- )} - -

- {ts(coinSource.nofxosNote, language)} -

-
-
- )}
) } diff --git a/web/src/components/strategy/IndicatorEditor.tsx b/web/src/components/strategy/IndicatorEditor.tsx index e54c1b56..81011d41 100644 --- a/web/src/components/strategy/IndicatorEditor.tsx +++ b/web/src/components/strategy/IndicatorEditor.tsx @@ -1,10 +1,5 @@ -import { Clock, Activity, TrendingUp, BarChart2, Info, Lock, ExternalLink, Zap, Check, AlertCircle, Key } from 'lucide-react' +import { Activity, BarChart2, Clock, Info, Lock, TrendingUp } from 'lucide-react' import type { IndicatorConfig } from '../../types' -import { indicator, ts } from '../../i18n/strategy-translations' -import { NofxSelect } from '../ui/select' - -// Default NofxOS API Key -const DEFAULT_NOFXOS_API_KEY = 'cm_568c67eae410d912c54c' interface IndicatorEditorProps { config: IndicatorConfig @@ -13,677 +8,218 @@ interface IndicatorEditorProps { language: string } -// All available timeframes -const allTimeframes = [ - { value: '1m', label: '1m', category: 'scalp' }, - { value: '3m', label: '3m', category: 'scalp' }, - { value: '5m', label: '5m', category: 'scalp' }, - { value: '15m', label: '15m', category: 'intraday' }, - { value: '30m', label: '30m', category: 'intraday' }, - { value: '1h', label: '1h', category: 'intraday' }, - { value: '2h', label: '2h', category: 'swing' }, - { value: '4h', label: '4h', category: 'swing' }, - { value: '6h', label: '6h', category: 'swing' }, - { value: '8h', label: '8h', category: 'swing' }, - { value: '12h', label: '12h', category: 'swing' }, - { value: '1d', label: '1D', category: 'position' }, - { value: '3d', label: '3D', category: 'position' }, - { value: '1w', label: '1W', category: 'position' }, +const t = (language: string, zh: string, en: string) => (language === 'zh' ? zh : en) + +const timeframes = [ + { value: '1m', label: '1m', group: 'scalp' }, + { value: '3m', label: '3m', group: 'scalp' }, + { value: '5m', label: '5m', group: 'scalp' }, + { value: '15m', label: '15m', group: 'intraday' }, + { value: '30m', label: '30m', group: 'intraday' }, + { value: '1h', label: '1h', group: 'intraday' }, + { value: '2h', label: '2h', group: 'swing' }, + { value: '4h', label: '4h', group: 'swing' }, + { value: '1d', label: '1D', group: 'position' }, ] -export function IndicatorEditor({ - config, - onChange, - disabled, - language, -}: IndicatorEditorProps) { - // Get currently selected timeframes - const selectedTimeframes = config.klines.selected_timeframes || [config.klines.primary_timeframe] +const groupLabels: Record = { + scalp: 'Scalp', + intraday: 'Intraday', + swing: 'Swing', + position: 'Position', +} - // Toggle timeframe selection - const toggleTimeframe = (tf: string) => { - if (disabled) return - const current = [...selectedTimeframes] - const index = current.indexOf(tf) +const indicatorCards = [ + { key: 'enable_ema', label: 'EMA', hint: '20/50', color: '#F0B90B' }, + { key: 'enable_macd', label: 'MACD', hint: 'trend momentum', color: '#a855f7' }, + { key: 'enable_rsi', label: 'RSI', hint: 'overbought/oversold', color: '#F6465D' }, + { key: 'enable_atr', label: 'ATR', hint: 'volatility risk', color: '#60a5fa' }, + { key: 'enable_boll', label: 'BOLL', hint: 'range / breakout', color: '#ec4899' }, +] as const - if (index >= 0) { - if (current.length > 1) { - current.splice(index, 1) - const newPrimary = tf === config.klines.primary_timeframe ? current[0] : config.klines.primary_timeframe - onChange({ - ...config, - klines: { - ...config.klines, - selected_timeframes: current, - primary_timeframe: newPrimary, - enable_multi_timeframe: current.length > 1, - }, - }) - } - } else { - if (current.length >= 4) { - // Show toast notification - const toast = document.createElement('div') - toast.textContent = language === 'zh' ? '最多选择 4 个时间维度' : 'Maximum 4 timeframes allowed' - toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg text-sm z-50 shadow-lg' - toast.style.cssText = 'background:#F6465D;color:#fff;' - document.body.appendChild(toast) - setTimeout(() => toast.remove(), 2000) - return - } - current.push(tf) - onChange({ - ...config, - klines: { - ...config.klines, - selected_timeframes: current, - enable_multi_timeframe: current.length > 1, - }, - }) - } - } +const marketContextCards = [ + { key: 'enable_volume', label: 'Volume', hint: 'from Hyperliquid candle volume', color: '#c084fc' }, + { key: 'enable_oi', label: 'Open Interest', hint: 'native exchange context when available', color: '#34d399' }, + { key: 'enable_funding_rate', label: 'Funding', hint: 'perp funding context', color: '#fbbf24' }, +] as const - // Set primary timeframe - const setPrimaryTimeframe = (tf: string) => { +export function IndicatorEditor({ config, onChange, disabled, language }: IndicatorEditorProps) { + const selectedTimeframes = config.klines.selected_timeframes || [config.klines.primary_timeframe || '5m'] + + const update = (patch: Partial) => { if (disabled) return onChange({ ...config, + // Ensure the simplified Hyperliquid strategy editor never enables NofxOSAI-only datasets. + nofxos_api_key: '', + enable_quant_data: false, + enable_quant_oi: false, + enable_quant_netflow: false, + enable_oi_ranking: false, + enable_netflow_ranking: false, + enable_price_ranking: false, + ...patch, + enable_raw_klines: true, + }) + } + + const toggleTimeframe = (tf: string) => { + if (disabled) return + const current = [...selectedTimeframes] + const exists = current.includes(tf) + if (exists && current.length === 1) return + const next = exists ? current.filter((item) => item !== tf) : [...current, tf].slice(0, 4) + const primary = next.includes(config.klines.primary_timeframe) ? config.klines.primary_timeframe : next[0] + update({ klines: { ...config.klines, - primary_timeframe: tf, + selected_timeframes: next, + primary_timeframe: primary, + enable_multi_timeframe: next.length > 1, }, }) } - const categoryColors: Record = { - scalp: '#F6465D', - intraday: '#F0B90B', - swing: '#0ECB81', - position: '#60a5fa', + const setPrimary = (tf: string) => { + if (disabled || !selectedTimeframes.includes(tf)) return + update({ klines: { ...config.klines, primary_timeframe: tf } }) } - // Ensure enable_raw_klines is always true - const ensureRawKlines = () => { - if (!config.enable_raw_klines) { - onChange({ ...config, enable_raw_klines: true }) - } + const toggleBool = (key: keyof IndicatorConfig) => { + update({ [key]: !config[key] } as Partial) } - // Call on mount if needed - if (config.enable_raw_klines === undefined || config.enable_raw_klines === false) { - ensureRawKlines() - } - - // Check if any NofxOS feature is enabled - const hasNofxosEnabled = config.enable_quant_data || config.enable_oi_ranking || config.enable_netflow_ranking || config.enable_price_ranking - const hasApiKey = !!config.nofxos_api_key - return (
- {/* ============================================ */} - {/* NofxOS Data Provider - Top Configuration */} - {/* ============================================ */} -
- {/* Decorative gradient line at top */} -
- -
- {/* Header Row */} -
-
-
- -
-
-

- {ts(indicator.nofxosTitle, language)} -

- - {ts(indicator.nofxosFeatures, language)} - -
-
- - {/* Status & API Docs */} -
- {hasApiKey ? ( - - - {ts(indicator.connected, language)} - - ) : ( - - - {ts(indicator.notConfigured, language)} - - )} - - - {ts(indicator.viewApiDocs, language)} - -
-
- - {/* API Key Input */} -
-
- - !disabled && onChange({ ...config, nofxos_api_key: e.target.value })} - disabled={disabled} - placeholder={ts(indicator.apiKeyPlaceholder, language)} - className="w-full pl-9 pr-3 py-2 rounded-lg text-sm font-mono" - style={{ - background: 'rgba(30, 35, 41, 0.8)', - border: hasApiKey ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid rgba(139, 92, 246, 0.3)', - color: '#EAECEF', - }} - /> -
- {!disabled && !config.nofxos_api_key && ( - - )} -
- - {/* NofxOS Data Sources Grid */} -
-
- {ts(indicator.nofxosDataSources, language)} -
-
- {/* Quant Data */} -
!disabled && onChange({ ...config, enable_quant_data: !config.enable_quant_data })} - > -
-
-
- {ts(indicator.quantData, language)} -
- { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) }} - disabled={disabled} - className="w-3.5 h-3.5 rounded accent-blue-500" - /> -
-

{ts(indicator.quantDataDesc, language)}

- {config.enable_quant_data && ( -
- - -
- )} -
- - {/* OI Ranking */} -
!disabled && onChange({ - ...config, - enable_oi_ranking: !config.enable_oi_ranking, - ...(!config.enable_oi_ranking && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}), - ...(!config.enable_oi_ranking && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}), - })} - > -
-
-
- {ts(indicator.oiRanking, language)} -
- { e.stopPropagation(); !disabled && onChange({ - ...config, - enable_oi_ranking: e.target.checked, - ...(e.target.checked && !config.oi_ranking_duration ? { oi_ranking_duration: '1h' } : {}), - ...(e.target.checked && !config.oi_ranking_limit ? { oi_ranking_limit: 10 } : {}), - }) }} - disabled={disabled} - className="w-3.5 h-3.5 rounded accent-green-500" - /> -
-

{ts(indicator.oiRankingDesc, language)}

- {config.enable_oi_ranking && ( -
e.stopPropagation()}> - !disabled && onChange({ ...config, oi_ranking_duration: val })} - disabled={disabled} - className="flex-1 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]} - /> - !disabled && onChange({ ...config, oi_ranking_limit: parseInt(val) })} - disabled={disabled} - className="w-14 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} - /> -
- )} -
- - {/* NetFlow Ranking */} -
!disabled && onChange({ - ...config, - enable_netflow_ranking: !config.enable_netflow_ranking, - ...(!config.enable_netflow_ranking && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}), - ...(!config.enable_netflow_ranking && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}), - })} - > -
-
-
- {ts(indicator.netflowRanking, language)} -
- { e.stopPropagation(); !disabled && onChange({ - ...config, - enable_netflow_ranking: e.target.checked, - ...(e.target.checked && !config.netflow_ranking_duration ? { netflow_ranking_duration: '1h' } : {}), - ...(e.target.checked && !config.netflow_ranking_limit ? { netflow_ranking_limit: 10 } : {}), - }) }} - disabled={disabled} - className="w-3.5 h-3.5 rounded accent-amber-500" - /> -
-

{ts(indicator.netflowRankingDesc, language)}

- {config.enable_netflow_ranking && ( -
e.stopPropagation()}> - !disabled && onChange({ ...config, netflow_ranking_duration: val })} - disabled={disabled} - className="flex-1 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[{ value: '1h', label: '1h' }, { value: '4h', label: '4h' }, { value: '24h', label: '24h' }]} - /> - !disabled && onChange({ ...config, netflow_ranking_limit: parseInt(val) })} - disabled={disabled} - className="w-14 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} - /> -
- )} -
- - {/* Price Ranking */} -
!disabled && onChange({ - ...config, - enable_price_ranking: !config.enable_price_ranking, - ...(!config.enable_price_ranking && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}), - ...(!config.enable_price_ranking && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}), - })} - > -
-
-
- {ts(indicator.priceRanking, language)} -
- { e.stopPropagation(); !disabled && onChange({ - ...config, - enable_price_ranking: e.target.checked, - ...(e.target.checked && !config.price_ranking_duration ? { price_ranking_duration: '1h,4h,24h' } : {}), - ...(e.target.checked && !config.price_ranking_limit ? { price_ranking_limit: 10 } : {}), - }) }} - disabled={disabled} - className="w-3.5 h-3.5 rounded accent-pink-500" - /> -
-

{ts(indicator.priceRankingDesc, language)}

- {config.enable_price_ranking && ( -
e.stopPropagation()}> - !disabled && onChange({ ...config, price_ranking_duration: val })} - disabled={disabled} - className="flex-1 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[ - { value: '1h', label: '1h' }, - { value: '4h', label: '4h' }, - { value: '24h', label: '24h' }, - { value: '1h,4h,24h', label: ts(indicator.priceRankingMulti, language) }, - ]} - /> - !disabled && onChange({ ...config, price_ranking_limit: parseInt(val) })} - disabled={disabled} - className="w-14 px-2 py-1 rounded text-[10px]" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - options={[5, 10, 15, 20].map(n => ({ value: n, label: String(n) }))} - /> -
- )} -
-
- - {/* Warning if features enabled but no API key */} - {hasNofxosEnabled && !hasApiKey && ( -
- - - {ts(indicator.configureApiKey, language)} - -
- )} -
+
+
+ +

{t(language, '真实行情输入', 'Real market inputs')}

+

+ {t( + language, + 'AI 只喂 Hyperliquid 原生 K 线、成交量、资金费率/持仓等交易所可用数据;不再混入外部聚合数据。', + 'AI uses native Hyperliquid candles, volume, funding/OI when available. External aggregate datasets are not mixed in.' + )} +

- {/* ============================================ */} - {/* Section 1: Market Data (Required) */} - {/* ============================================ */} -
-
- - {ts(indicator.marketData, language)} - - {ts(indicator.marketDataDesc, language)} -
- -
- {/* Raw Klines - Required, Always On */} -
-
-
- -
-
-
- {ts(indicator.rawKlines, language)} - - - {ts(indicator.required, language)} - -
-

{ts(indicator.rawKlinesDesc, language)}

-
+
+
+
+
+ + {t(language, 'K 线数据', 'Candles')} + + + {t(language, '必需', 'Required')} +
+

+ {t(language, '来自 Hyperliquid candleSnapshot。最多选择 4 个时间周期。', 'From Hyperliquid candleSnapshot. Select up to 4 timeframes.')} +

+
+
+ {t(language, '根数', 'Bars')} update({ klines: { ...config.klines, primary_count: Number(e.target.value) || 20 } })} + className="w-16 rounded-lg border border-white/10 bg-nofx-bg px-2 py-1 text-center text-nofx-text" />
+
- {/* Timeframe Selection */} -
-
-
- - {ts(indicator.timeframes, language)} -
-
- {ts(indicator.klineCount, language)}: - - !disabled && - onChange({ - ...config, - klines: { ...config.klines, primary_count: parseInt(e.target.value) || 30 }, - }) - } - disabled={disabled} - min={10} - max={30} - className="w-16 px-2 py-1 rounded text-xs text-center" - style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} - /> +
+ {['scalp', 'intraday', 'swing', 'position'].map((group) => ( +
+ {groupLabels[group]} +
+ {timeframes.filter((tf) => tf.group === group).map((tf) => { + const selected = selectedTimeframes.includes(tf.value) + const primary = config.klines.primary_timeframe === tf.value + return ( + + ) + })}
-

{ts(indicator.timeframesDesc, language)}

- - {/* Timeframe Grid */} -
- {(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => { - const categoryTfs = allTimeframes.filter((tf) => tf.category === category) - return ( -
- - {ts(indicator[category], language)} - -
- {categoryTfs.map((tf) => { - const isSelected = selectedTimeframes.includes(tf.value) - const isPrimary = config.klines.primary_timeframe === tf.value - return ( - - ) - })} -
-
- ) - })} -
-
+ ))}
- {/* ============================================ */} - {/* Section 2: Technical Indicators (Optional) */} - {/* ============================================ */} -
-
- - {ts(indicator.technicalIndicators, language)} - - {ts(indicator.technicalIndicatorsDesc, language)} +
+
+ + {t(language, '可选技术指标', 'Optional technical indicators')}
- -
- {/* Tip */} -
- -

{ts(indicator.aiCanCalculate, language)}

-
- - {/* Indicator Grid */} -
- {[ - { key: 'enable_ema', label: 'ema', desc: 'emaDesc', color: '#F0B90B', periodKey: 'ema_periods', defaultPeriods: '20,50' }, - { key: 'enable_macd', label: 'macd', desc: 'macdDesc', color: '#a855f7' }, - { key: 'enable_rsi', label: 'rsi', desc: 'rsiDesc', color: '#F6465D', periodKey: 'rsi_periods', defaultPeriods: '7,14' }, - { key: 'enable_atr', label: 'atr', desc: 'atrDesc', color: '#60a5fa', periodKey: 'atr_periods', defaultPeriods: '14' }, - { key: 'enable_boll', label: 'boll', desc: 'bollDesc', color: '#ec4899', periodKey: 'boll_periods', defaultPeriods: '20' }, - ].map(({ key, label, desc, color, periodKey, defaultPeriods }) => ( -
+ + {t(language, '默认只给原始 K 线,AI 可以自己计算。需要固定指标时再开启。', 'Raw candles are enough by default; enable fixed indicators only when needed.')} +
+
+ {indicatorCards.map(({ key, label, hint, color }) => { + const enabled = Boolean(config[key]) + return ( + + ) + })}
- {/* ============================================ */} - {/* Section 3: Market Sentiment */} - {/* ============================================ */} -
-
- - {ts(indicator.marketSentiment, language)} - - {ts(indicator.marketSentimentDesc, language)} +
+
+ + {t(language, '交易所上下文', 'Exchange context')}
- -
-
- {[ - { key: 'enable_volume', label: 'volume', desc: 'volumeDesc', color: '#c084fc' }, - { key: 'enable_oi', label: 'oi', desc: 'oiDesc', color: '#34d399' }, - { key: 'enable_funding_rate', label: 'fundingRate', desc: 'fundingRateDesc', color: '#fbbf24' }, - ].map(({ key, label, desc, color }) => ( -
+ {marketContextCards.map(({ key, label, hint, color }) => { + const enabled = Boolean(config[key]) + return ( +
+
{label}
+
{hint}
+ + ) + })}
diff --git a/web/src/i18n/strategy-translations.ts b/web/src/i18n/strategy-translations.ts index f3b10e42..8b444f90 100644 --- a/web/src/i18n/strategy-translations.ts +++ b/web/src/i18n/strategy-translations.ts @@ -12,6 +12,8 @@ export const coinSource = { ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider', es: 'Proveedor AI500' }, oi_top: { zh: 'OI 持仓增加', en: 'OI Increase', es: 'Aumento OI' }, oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease', es: 'Disminución OI' }, + hyper_all: { zh: 'Hyperliquid 全市场', en: 'Hyperliquid All Markets', es: 'Hyperliquid Todos' }, + hyper_main: { zh: 'Hyperliquid 主流市场', en: 'Hyperliquid Main Markets', es: 'Hyperliquid Principales' }, staticCoins: { zh: '自定义币种', en: 'Custom Coins', es: 'Monedas Personalizadas' }, addCoin: { zh: '添加币种', en: 'Add Coin', es: 'Agregar Moneda' }, useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider', es: 'Habilitar AI500' }, @@ -20,6 +22,9 @@ export const coinSource = { oiTopLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' }, useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease', es: 'Habilitar Disminución OI' }, oiLowLimit: { zh: '数量上限', en: 'Limit', es: 'Límite' }, + useHyperAll: { zh: '启用 Hyperliquid 全部 USDC 合约', en: 'Enable all Hyperliquid USDC perps', es: 'Habilitar todos Hyperliquid USDC' }, + useHyperMain: { zh: '启用 Hyperliquid 成交额主流市场', en: 'Enable top Hyperliquid markets by volume', es: 'Habilitar principales por volumen' }, + hyperMainLimit: { zh: '扫描数量', en: 'Scan Limit', es: 'Límite' }, staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins', es: 'Especificar monedas manualmente' }, maxCoins: { zh: '最多', en: 'Up to', es: 'Hasta' }, coins: { zh: '个币种', en: 'coins', es: 'monedas' }, @@ -31,6 +36,9 @@ export const coinSource = { ai500Desc: { zh: '使用 AI500 智能筛选的热门币种', en: 'Use AI500 smart-filtered popular coins', es: 'Monedas filtradas por AI500' }, oi_topDesc: { zh: '持仓增加榜,适合做多', en: 'OI increase ranking, for long', es: 'Ranking OI creciente, para largo' }, oi_lowDesc: { zh: '持仓减少榜,适合做空', en: 'OI decrease ranking, for short', es: 'Ranking OI decreciente, para corto' }, + hyper_allDesc: { zh: '一键扫描 Hyperliquid 所有 USDC 美股/大宗/加密合约', en: 'Scan every Hyperliquid USDC equity, commodity and crypto perp', es: 'Escanear todos los perps USDC' }, + hyper_mainDesc: { zh: '优先扫描成交额最高的 Hyperliquid USDC 市场', en: 'Scan the highest-volume Hyperliquid USDC markets first', es: 'Escanear mercados USDC principales' }, + hyperAssetsNote: { zh: '覆盖 TSLA、NVDA、AAPL、GOLD、SILVER、BTC、ETH 等 Hyperliquid 上线的 USDC 合约;资产列表由 Hyperliquid 实时拉取。', en: 'Covers listed Hyperliquid USDC perps such as TSLA, NVDA, AAPL, GOLD, SILVER, BTC and ETH; the tradable universe is pulled live from Hyperliquid.', es: 'Cubre perps USDC listados en Hyperliquid.' }, oiIncreaseShort: { zh: 'OI增', en: 'OI↑', es: 'OI↑' }, oiDecreaseShort: { zh: 'OI减', en: 'OI↓', es: 'OI↓' }, custom: { zh: '自定义', en: 'Custom', es: 'Personalizado' }, diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index cb0a547a..045e9243 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -24,7 +24,6 @@ import { Clock, Bot, Terminal, - Code, Send, Download, Upload, @@ -41,7 +40,7 @@ import { confirmToast, notify } from '../lib/notify' import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor' import { IndicatorEditor } from '../components/strategy/IndicatorEditor' import { RiskControlEditor } from '../components/strategy/RiskControlEditor' -import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor' + import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor' import { GridConfigEditor, @@ -53,6 +52,33 @@ import { t } from '../i18n/translations' const API_BASE = import.meta.env.VITE_API_BASE || '' + +const strategyPromptTemplates = [ + '只做强势美股', + '低波动稳健做多', + '突破新高买入', + '回调到均线买入', + '只交易大盘股', + '避开财报日', + '外汇顺势交易', + '黄金回调做多', + 'PreIPO强者恒强', + '亏损立即降仓', +] + +const strategyPromptTemplatesEn = [ + 'Buy strong US stocks', + 'Low-vol steady longs', + 'Buy fresh breakouts', + 'Buy MA pullbacks', + 'Trade mega caps only', + 'Avoid earnings days', + 'Follow FX trends', + 'Buy gold pullbacks', + 'Pre-IPO momentum', + 'Cut losers fast', +] + const getAIConfig = (config: StrategyConfig): AIStrategyConfig | null => { if (config.ai_config) return config.ai_config if (config.coin_source && config.indicators && config.risk_control) { @@ -79,6 +105,32 @@ const normalizeStrategyConfig = (config: StrategyConfig): StrategyConfig => { } } +const isHyperliquidCoinSource = (source?: AIStrategyConfig['coin_source']) => { + if (!source) return false + return source.source_type === 'hyper_all' || source.source_type === 'hyper_main' || source.source_type === 'hyper_rank' || source.use_hyper_all || source.use_hyper_main +} + +const stripNofxOSDataForHyperliquid = (config: StrategyConfig): StrategyConfig => { + const normalized = normalizeStrategyConfig(config) + if (!normalized.ai_config || !isHyperliquidCoinSource(normalized.ai_config.coin_source)) return normalized + return { + ...normalized, + ai_config: { + ...normalized.ai_config, + indicators: { + ...normalized.ai_config.indicators, + nofxos_api_key: '', + enable_quant_data: false, + enable_quant_oi: false, + enable_quant_netflow: false, + enable_oi_ranking: false, + enable_netflow_ranking: false, + enable_price_ranking: false, + }, + }, + } +} + export function StrategyStudioPage() { const { token } = useAuth() const { language } = useLanguage() @@ -122,7 +174,7 @@ export function StrategyStudioPage() { config_summary: Record } | null>(null) const [isLoadingPrompt, setIsLoadingPrompt] = useState(false) - const [selectedVariant, setSelectedVariant] = useState('balanced') + const selectedVariant = 'balanced' // AI Test Run states const [aiTestResult, setAiTestResult] = useState<{ @@ -511,7 +563,7 @@ export function StrategyStudioPage() { try { // Always sync the config language with the current interface language const configWithLanguage = { - ...normalizeStrategyConfig(editingConfig), + ...stripNofxOSDataForHyperliquid(editingConfig), language: language as 'zh' | 'en', } const response = await fetch( @@ -759,40 +811,39 @@ export function StrategyStudioPage() { /> ), }, - { - key: 'promptSections' as const, - icon: FileText, - color: '#a855f7', - title: tr('promptSections'), - forStrategyType: 'ai_trading' as const, - content: currentAIConfig && ( - - updateAIConfig('prompt_sections', promptSections) - } - disabled={selectedStrategy?.is_default} - language={language} - /> - ), - }, { key: 'customPrompt' as const, icon: Settings, color: '#60a5fa', - title: tr('customPrompt'), + title: language === 'zh' ? '策略说明' : 'Strategy prompt', forStrategyType: 'ai_trading' as const, content: currentAIConfig && ( -
-

- {tr('customPromptDesc')} +

+

+ {language === 'zh' + ? '写策略很简单:点一句话模板,或直接写一句你的交易想法。' + : 'Strategy writing is simple: click a one-line template or type one trading idea.'}

+
+ {(language === 'zh' ? strategyPromptTemplates : strategyPromptTemplatesEn).map((template) => ( + + ))} +