mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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
This commit is contained in:
126
kernel/engine.go
126
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 == "" {
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
42
store/strategy_hyperliquid_defaults_test.go
Normal file
42
store/strategy_hyperliquid_defaults_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<MarketSymbol[]>([])
|
||||
const [loadingSymbols, setLoadingSymbols] = useState(false)
|
||||
const [symbolError, setSymbolError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [category, setCategory] = useState<string>('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 = () => (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-medium bg-purple-500/20 text-purple-400 border border-purple-500/30"
|
||||
>
|
||||
NofxOS
|
||||
</span>
|
||||
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<CoinSourceConfig>) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Source Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{ts(coinSource.sourceType, language)}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border border-sky-400/20 bg-gradient-to-br from-sky-500/10 via-nofx-bg-lighter to-nofx-bg p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-nofx-text">
|
||||
<Globe2 className="w-5 h-5 text-sky-300" />
|
||||
<h3 className="font-semibold">
|
||||
{t(language, 'Hyperliquid 原生标的', 'Native Hyperliquid universe')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-nofx-text-muted">
|
||||
{t(
|
||||
language,
|
||||
'只使用 Hyperliquid 实时 Universe / K 线 / 标记价格;不混入外部聚合数据。',
|
||||
'Uses Hyperliquid live universe, candles and mark prices only; no external aggregate datasets are mixed in.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] text-sky-200">
|
||||
{symbols.length || '—'} {t(language, '个可视化标的', 'visual markets')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
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 (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
!disabled &&
|
||||
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
|
||||
}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`p-4 rounded-lg border transition-all ${config.source_type === value
|
||||
? 'ring-2 ring-nofx-gold bg-nofx-gold/10'
|
||||
: 'hover:bg-white/5 bg-nofx-bg'
|
||||
} border-nofx-gold/20`}
|
||||
onClick={() => chooseSource(value)}
|
||||
className={`rounded-xl border p-4 text-left transition-all ${
|
||||
active
|
||||
? 'border-sky-300/70 bg-sky-400/10 shadow-[0_0_24px_rgba(56,189,248,0.12)]'
|
||||
: 'border-white/10 bg-nofx-bg hover:border-sky-400/40 hover:bg-white/[0.03]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} />
|
||||
<div className="text-sm font-medium text-nofx-text">
|
||||
{ts(coinSource[value as keyof typeof coinSource], language)}
|
||||
</div>
|
||||
<div className="text-xs mt-1 text-nofx-text-muted">
|
||||
{ts(coinSource[`${value}Desc` as keyof typeof coinSource], language)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Icon className="w-5 h-5 text-sky-300" />
|
||||
{active && <Check className="w-4 h-4 text-sky-300" />}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold text-nofx-text">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-nofx-text-muted">{desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Static Coins - only for static mode */}
|
||||
{config.source_type === 'static' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||
{ts(coinSource.staticCoins, language)}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.static_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-bg-lighter text-nofx-text"
|
||||
{config.source_type === 'hyper_rank' && (
|
||||
<div className="space-y-3 rounded-2xl border border-violet-400/20 bg-violet-500/5 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-nofx-text">{t(language, '榜单规则', 'Ranking rule')}</div>
|
||||
<div className="text-xs text-nofx-text-muted">{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.')}</div>
|
||||
</div>
|
||||
<select
|
||||
value={Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateRank({ hyper_rank_limit: Math.min(Number(e.target.value) || DEFAULT_RANK_LIMIT, RANK_LIMIT) })}
|
||||
className="rounded-lg border border-violet-300/20 bg-nofx-bg px-3 py-1.5 text-sm text-nofx-text"
|
||||
>
|
||||
{Array.from({ length: RANK_LIMIT }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={n}>Top {n}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{[...categoryOrder, 'all'].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => updateRank({ hyper_rank_category: cat as CoinSourceConfig['hyper_rank_category'] })}
|
||||
className={`rounded-xl border px-3 py-2 text-xs transition-all ${
|
||||
(config.hyper_rank_category || 'stock') === cat
|
||||
? 'border-sky-300/70 bg-sky-400/10 text-sky-100'
|
||||
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveCoin(coin)}
|
||||
className="ml-1 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCoin}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{rankDirections.map((item) => (
|
||||
<button
|
||||
onClick={handleAddCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors bg-nofx-gold text-black hover:bg-yellow-500"
|
||||
key={item.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => updateRank({ hyper_rank_direction: item.value })}
|
||||
className={`rounded-xl border px-3 py-2 text-sm transition-all ${
|
||||
(config.hyper_rank_direction || 'gainers') === item.value
|
||||
? 'border-violet-300/70 bg-violet-400/10 text-violet-100'
|
||||
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{ts(coinSource.addCoin, language)}
|
||||
{t(language, item.labelZh, item.labelEn)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{rankedPreview.map((symbol, index) => (
|
||||
<div key={symbol.symbol} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-nofx-text-muted">#{index + 1}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-nofx-text">{displaySymbol(symbol)}</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px]">
|
||||
<span className="text-nofx-text-muted">Vol {formatCompactNumber(symbol.volume_24h)}</span>
|
||||
{typeof symbol.change_24h_pct === 'number' && (
|
||||
<span className={symbol.change_24h_pct >= 0 ? 'text-nofx-success' : 'text-nofx-danger'}>
|
||||
{symbol.change_24h_pct >= 0 ? '+' : ''}{symbol.change_24h_pct.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Excluded Coins */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ban className="w-4 h-4 text-nofx-danger" />
|
||||
<label className="text-sm font-medium text-nofx-text">
|
||||
{ts(coinSource.excludedCoins, language)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs mb-3 text-nofx-text-muted">
|
||||
{ts(coinSource.excludedCoinsDesc, language)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.excluded_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm bg-nofx-danger/15 text-nofx-danger"
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveExcludedCoin(coin)}
|
||||
className="ml-1 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm font-medium text-nofx-text">
|
||||
<span>{t(language, '自选标的', 'Selected markets')}</span>
|
||||
<span className="text-xs font-normal text-nofx-text-muted">{selectedCoins.length}/{SELECTED_MARKET_LIMIT}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedMarketSymbols.length > 0 ? selectedMarketSymbols.map((symbol) => (
|
||||
<span key={symbol.symbol} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-sm text-sky-100">
|
||||
{displaySymbol(symbol)}
|
||||
{!disabled && (
|
||||
<button type="button" onClick={() => removeSymbol(symbol.symbol)} className="text-sky-200 hover:text-white">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)) : (
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{t(language, '点击下方标的卡片添加。', 'Click market cards below to add.')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(config.excluded_coins || []).length === 0 && (
|
||||
<span className="text-xs italic text-nofx-text-muted">
|
||||
{ts(coinSource.excludedNone, language)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newExcludedCoin}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddExcludedCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors text-sm bg-nofx-danger text-white hover:bg-red-600"
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
{ts(coinSource.addExcludedCoin, language)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI500 Options - only for ai500 mode */}
|
||||
{config.source_type === 'ai500' && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-nofx-gold" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
AI500 {ts(coinSource.dataSourceConfig, language)}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_ai500}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_ai500: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-nofx-gold"
|
||||
/>
|
||||
<span className="text-nofx-text">{ts(coinSource.useAI500, language)}</span>
|
||||
</label>
|
||||
|
||||
{config.use_ai500 && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.ai500Limit, language)}:
|
||||
</span>
|
||||
<NofxSelect
|
||||
value={config.ai500_limit || 3}
|
||||
onChange={(val) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, ai500_limit: parseInt(val) || 3 })
|
||||
}
|
||||
<div className="rounded-2xl border border-white/10 bg-nofx-bg/80 p-3">
|
||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-nofx-text-muted" />
|
||||
<input
|
||||
value={query}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{ts(coinSource.nofxosNote, language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options - only for oi_top mode */}
|
||||
{config.source_type === 'oi_top' && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{ts(coinSource.oiIncreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['all', ...categoryOrder].map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] transition-colors ${
|
||||
category === cat ? 'bg-sky-400 text-black' : 'bg-white/5 text-nofx-text-muted hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-nofx-success"
|
||||
/>
|
||||
<span className="text-nofx-text">{ts(coinSource.useOITop, language)}</span>
|
||||
</label>
|
||||
{loadingSymbols && <div className="py-8 text-center text-sm text-nofx-text-muted">{t(language, '加载 Hyperliquid 标的中…', 'Loading Hyperliquid markets…')}</div>}
|
||||
{symbolError && <div className="py-6 text-center text-sm text-nofx-danger">{symbolError}</div>}
|
||||
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiTopLimit, language)}:
|
||||
</span>
|
||||
<NofxSelect
|
||||
value={config.oi_top_limit || 3}
|
||||
onChange={(val) =>
|
||||
!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 && (
|
||||
<div className="grid max-h-[420px] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredSymbols.map((symbol) => {
|
||||
const selected = selectedSet.has(symbol.symbol)
|
||||
const change = symbol.change_24h_pct
|
||||
return (
|
||||
<button
|
||||
key={symbol.symbol}
|
||||
type="button"
|
||||
disabled={disabled || selected || selectedCoins.length >= SELECTED_MARKET_LIMIT}
|
||||
onClick={() => addSymbol(symbol)}
|
||||
className={`rounded-xl border p-3 text-left transition-all ${
|
||||
selected
|
||||
? 'border-sky-300/50 bg-sky-400/10'
|
||||
: 'border-white/10 bg-white/[0.02] hover:border-sky-400/40 hover:bg-sky-400/[0.06]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-nofx-text">{displaySymbol(symbol)}</div>
|
||||
<div className="mt-0.5 text-[11px] text-nofx-text-muted">{categoryLabels[symbol.category || 'crypto'] || symbol.category || 'Crypto'}</div>
|
||||
</div>
|
||||
{selected && <Check className="w-4 h-4 text-sky-300" />}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-[11px]">
|
||||
<span className="text-nofx-text-muted">Vol {formatCompactNumber(symbol.volume_24h)}</span>
|
||||
{typeof change === 'number' && (
|
||||
<span className={change >= 0 ? 'text-nofx-success' : 'text-nofx-danger'}>
|
||||
{change >= 0 ? '+' : ''}{change.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{ts(coinSource.nofxosNote, language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Low Options - only for oi_low mode */}
|
||||
{config.source_type === 'oi_low' && (
|
||||
<div
|
||||
className="p-4 rounded-lg bg-nofx-danger/5 border border-nofx-danger/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
||||
<span className="text-sm font-medium text-nofx-text">
|
||||
{ts(coinSource.oiDecreaseTitle, language)} {ts(coinSource.dataSourceConfig, language)}
|
||||
</span>
|
||||
<NofxOSBadge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_low}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_low: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-red-500"
|
||||
/>
|
||||
<span className="text-nofx-text">{ts(coinSource.useOILow, language)}</span>
|
||||
</label>
|
||||
|
||||
{config.use_oi_low && (
|
||||
<div className="flex items-center gap-3 pl-8">
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
{ts(coinSource.oiLowLimit, language)}:
|
||||
</span>
|
||||
<NofxSelect
|
||||
value={config.oi_low_limit || 3}
|
||||
onChange={(val) =>
|
||||
!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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
||||
{ts(coinSource.nofxosNote, language)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<IndicatorConfig>) => {
|
||||
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<string, string> = {
|
||||
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<IndicatorConfig>)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-5">
|
||||
{/* ============================================ */}
|
||||
{/* NofxOS Data Provider - Top Configuration */}
|
||||
{/* ============================================ */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden relative"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.08) 0%, rgba(168, 85, 247, 0.08) 50%, rgba(236, 72, 153, 0.08) 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
>
|
||||
{/* Decorative gradient line at top */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[2px]"
|
||||
style={{ background: 'linear-gradient(90deg, #6366f1, #a855f7, #ec4899)' }}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #a855f7)' }}
|
||||
>
|
||||
<Zap className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{ts(indicator.nofxosTitle, language)}
|
||||
</h3>
|
||||
<span className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{ts(indicator.nofxosFeatures, language)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status & API Docs */}
|
||||
<div className="flex items-center gap-2">
|
||||
{hasApiKey ? (
|
||||
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
|
||||
<Check className="w-3 h-3" />
|
||||
{ts(indicator.connected, language)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full" style={{ background: 'rgba(246, 70, 93, 0.15)', color: '#F6465D' }}>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{ts(indicator.notConfigured, language)}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="https://nofxos.ai/api-docs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-all hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'rgba(139, 92, 246, 0.2)',
|
||||
color: '#a855f7',
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{ts(indicator.viewApiDocs, language)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Input */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Key className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={config.nofxos_api_key || ''}
|
||||
onChange={(e) => !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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!disabled && !config.nofxos_api_key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...config, nofxos_api_key: DEFAULT_NOFXOS_API_KEY })}
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium transition-all hover:scale-[1.02]"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #6366f1, #a855f7)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{ts(indicator.fillDefault, language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NofxOS Data Sources Grid */}
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] font-medium mb-2" style={{ color: '#848E9C' }}>
|
||||
{ts(indicator.nofxosDataSources, language)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Quant Data */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_quant_data ? 'rgba(96, 165, 250, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_quant_data ? '1px solid rgba(96, 165, 250, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !disabled && onChange({ ...config, enable_quant_data: !config.enable_quant_data })}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.quantData, language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_data || false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_data: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3.5 h-3.5 rounded accent-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.quantDataDesc, language)}</p>
|
||||
{config.enable_quant_data && (
|
||||
<div className="flex gap-3 mt-2">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_oi !== false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_oi: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3 h-3 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: '#EAECEF' }}>OI</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_quant_netflow !== false}
|
||||
onChange={(e) => { e.stopPropagation(); !disabled && onChange({ ...config, enable_quant_netflow: e.target.checked }) }}
|
||||
disabled={disabled}
|
||||
className="w-3 h-3 rounded accent-blue-500"
|
||||
/>
|
||||
<span className="text-[10px]" style={{ color: '#EAECEF' }}>Netflow</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OI Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_oi_ranking ? 'rgba(34, 197, 94, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_oi_ranking ? '1px solid rgba(34, 197, 94, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !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 } : {}),
|
||||
})}
|
||||
>
|
||||
<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 font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.oiRanking, language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_oi_ranking || false}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.oiRankingDesc, language)}</p>
|
||||
{config.enable_oi_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<NofxSelect
|
||||
value={config.oi_ranking_duration || '1h'}
|
||||
onChange={(val) => !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' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.oi_ranking_limit || 10}
|
||||
onChange={(val) => !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) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NetFlow Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_netflow_ranking ? 'rgba(245, 158, 11, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_netflow_ranking ? '1px solid rgba(245, 158, 11, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !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 } : {}),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#f59e0b' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.netflowRanking, language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_netflow_ranking || false}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.netflowRankingDesc, language)}</p>
|
||||
{config.enable_netflow_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<NofxSelect
|
||||
value={config.netflow_ranking_duration || '1h'}
|
||||
onChange={(val) => !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' }]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.netflow_ranking_limit || 10}
|
||||
onChange={(val) => !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) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price Ranking */}
|
||||
<div
|
||||
className="p-2.5 rounded-lg transition-all cursor-pointer"
|
||||
style={{
|
||||
background: config.enable_price_ranking ? 'rgba(236, 72, 153, 0.1)' : 'rgba(30, 35, 41, 0.5)',
|
||||
border: config.enable_price_ranking ? '1px solid rgba(236, 72, 153, 0.3)' : '1px solid rgba(43, 49, 57, 0.5)',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => !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 } : {}),
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: '#ec4899' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.priceRanking, language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enable_price_ranking || false}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] mt-1" style={{ color: '#5E6673' }}>{ts(indicator.priceRankingDesc, language)}</p>
|
||||
{config.enable_price_ranking && (
|
||||
<div className="flex gap-2 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<NofxSelect
|
||||
value={config.price_ranking_duration || '1h,4h,24h'}
|
||||
onChange={(val) => !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) },
|
||||
]}
|
||||
/>
|
||||
<NofxSelect
|
||||
value={config.price_ranking_limit || 10}
|
||||
onChange={(val) => !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) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning if features enabled but no API key */}
|
||||
{hasNofxosEnabled && !hasApiKey && (
|
||||
<div className="flex items-center gap-2 mt-3 p-2 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" style={{ color: '#F6465D' }} />
|
||||
<span className="text-[10px]" style={{ color: '#F6465D' }}>
|
||||
{ts(indicator.configureApiKey, language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sky-400/20 bg-sky-500/5 p-4">
|
||||
<div className="flex items-center gap-2 text-nofx-text">
|
||||
<BarChart2 className="h-5 w-5 text-sky-300" />
|
||||
<h3 className="font-semibold">{t(language, '真实行情输入', 'Real market inputs')}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-nofx-text-muted">
|
||||
{t(
|
||||
language,
|
||||
'AI 只喂 Hyperliquid 原生 K 线、成交量、资金费率/持仓等交易所可用数据;不再混入外部聚合数据。',
|
||||
'AI uses native Hyperliquid candles, volume, funding/OI when available. External aggregate datasets are not mixed in.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* Section 1: Market Data (Required) */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<BarChart2 className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.marketData, language)}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {ts(indicator.marketDataDesc, language)}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-4">
|
||||
{/* Raw Klines - Required, Always On */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg" style={{ background: 'rgba(240, 185, 11, 0.08)', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.15)' }}>
|
||||
<TrendingUp className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.rawKlines, language)}</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex items-center gap-1" style={{ background: 'rgba(240, 185, 11, 0.2)', color: '#F0B90B' }}>
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
{ts(indicator.required, language)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: '#848E9C' }}>{ts(indicator.rawKlinesDesc, language)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
||||
<TrendingUp className="h-4 w-4 text-nofx-gold" />
|
||||
{t(language, 'K 线数据', 'Candles')}
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-nofx-gold/15 px-2 py-0.5 text-[10px] text-nofx-gold">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(language, '必需', 'Required')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-nofx-text-muted">
|
||||
{t(language, '来自 Hyperliquid candleSnapshot。最多选择 4 个时间周期。', 'From Hyperliquid candleSnapshot. Select up to 4 timeframes.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-nofx-text-muted">
|
||||
{t(language, '根数', 'Bars')}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={true}
|
||||
disabled={true}
|
||||
className="w-5 h-5 rounded accent-yellow-500 cursor-not-allowed"
|
||||
type="number"
|
||||
min={10}
|
||||
max={60}
|
||||
value={config.klines.primary_count || 20}
|
||||
disabled={disabled}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeframe Selection */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.timeframes, language)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px]" style={{ color: '#848E9C' }}>{ts(indicator.klineCount, language)}:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.klines.primary_count}
|
||||
onChange={(e) =>
|
||||
!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' }}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{['scalp', 'intraday', 'swing', 'position'].map((group) => (
|
||||
<div key={group} className="flex items-center gap-3">
|
||||
<span className="w-16 text-[11px] text-nofx-text-muted">{groupLabels[group]}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{timeframes.filter((tf) => tf.group === group).map((tf) => {
|
||||
const selected = selectedTimeframes.includes(tf.value)
|
||||
const primary = config.klines.primary_timeframe === tf.value
|
||||
return (
|
||||
<button
|
||||
key={tf.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleTimeframe(tf.value)}
|
||||
onDoubleClick={() => setPrimary(tf.value)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs transition-all ${
|
||||
selected
|
||||
? 'border-nofx-gold bg-nofx-gold/10 text-nofx-gold'
|
||||
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
|
||||
}`}
|
||||
title={primary ? 'Primary timeframe' : 'Double click selected item to make primary'}
|
||||
>
|
||||
{tf.label}{primary && ' ★'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] mb-2" style={{ color: '#5E6673' }}>{ts(indicator.timeframesDesc, language)}</p>
|
||||
|
||||
{/* Timeframe Grid */}
|
||||
<div className="space-y-1.5">
|
||||
{(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => {
|
||||
const categoryTfs = allTimeframes.filter((tf) => tf.category === category)
|
||||
return (
|
||||
<div key={category} className="flex items-center gap-2">
|
||||
<span className="text-[10px] w-10 flex-shrink-0" style={{ color: categoryColors[category] }}>
|
||||
{ts(indicator[category], language)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryTfs.map((tf) => {
|
||||
const isSelected = selectedTimeframes.includes(tf.value)
|
||||
const isPrimary = config.klines.primary_timeframe === tf.value
|
||||
return (
|
||||
<button
|
||||
key={tf.value}
|
||||
onClick={() => toggleTimeframe(tf.value)}
|
||||
onDoubleClick={() => setPrimaryTimeframe(tf.value)}
|
||||
disabled={disabled}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
isSelected ? '' : 'opacity-40 hover:opacity-70'
|
||||
}`}
|
||||
style={{
|
||||
background: isSelected ? `${categoryColors[category]}15` : 'transparent',
|
||||
border: `1px solid ${isSelected ? categoryColors[category] : '#2B3139'}`,
|
||||
color: isSelected ? categoryColors[category] : '#848E9C',
|
||||
boxShadow: isPrimary ? `0 0 0 2px ${categoryColors[category]}` : undefined,
|
||||
}}
|
||||
title={isPrimary ? `${tf.label} (Primary)` : tf.label}
|
||||
>
|
||||
{tf.label}
|
||||
{isPrimary && <span className="ml-0.5 text-[8px]">★</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* Section 2: Technical Indicators (Optional) */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.technicalIndicators, language)}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {ts(indicator.technicalIndicatorsDesc, language)}</span>
|
||||
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
||||
<Activity className="h-4 w-4 text-nofx-success" />
|
||||
{t(language, '可选技术指标', 'Optional technical indicators')}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{/* Tip */}
|
||||
<div className="flex items-start gap-2 mb-3 p-2 rounded" style={{ background: 'rgba(14, 203, 129, 0.05)' }}>
|
||||
<Info className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" style={{ color: '#0ECB81' }} />
|
||||
<p className="text-[10px]" style={{ color: '#848E9C' }}>{ts(indicator.aiCanCalculate, language)}</p>
|
||||
</div>
|
||||
|
||||
{/* Indicator Grid */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div
|
||||
<div className="mb-3 flex items-start gap-2 rounded-xl bg-nofx-success/5 p-3 text-xs text-nofx-text-muted">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-nofx-success" />
|
||||
{t(language, '默认只给原始 K 线,AI 可以自己计算。需要固定指标时再开启。', 'Raw candles are enough by default; enable fixed indicators only when needed.')}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{indicatorCards.map(({ key, label, hint, color }) => {
|
||||
const enabled = Boolean(config[key])
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className="p-2.5 rounded-lg transition-all"
|
||||
style={{
|
||||
background: config[key as keyof IndicatorConfig] ? `${color}08` : 'transparent',
|
||||
border: `1px solid ${config[key as keyof IndicatorConfig] ? `${color}30` : '#2B3139'}`,
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleBool(key)}
|
||||
className={`rounded-xl border p-3 text-left transition-all ${enabled ? 'bg-white/[0.04]' : 'bg-transparent hover:bg-white/[0.03]'}`}
|
||||
style={{ borderColor: enabled ? `${color}66` : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: color }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator[label as keyof typeof indicator], language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config[key as keyof IndicatorConfig] as boolean || false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, [key]: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-yellow-500"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm font-medium text-nofx-text">
|
||||
<span>{label}</span>
|
||||
<span className="h-2 w-2 rounded-full" style={{ background: enabled ? color : '#5E6673' }} />
|
||||
</div>
|
||||
<p className="text-[10px] mb-1.5" style={{ color: '#5E6673' }}>{ts(indicator[desc as keyof typeof indicator], language)}</p>
|
||||
{periodKey && config[key as keyof IndicatorConfig] && (
|
||||
<input
|
||||
type="text"
|
||||
value={(config[periodKey as keyof IndicatorConfig] as number[])?.join(',') || defaultPeriods}
|
||||
onChange={(e) => {
|
||||
if (disabled) return
|
||||
const periods = e.target.value
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
onChange({ ...config, [periodKey]: periods })
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={defaultPeriods}
|
||||
className="w-full px-2 py-1 rounded text-[10px] text-center"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-nofx-text-muted">{hint}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ============================================ */}
|
||||
{/* Section 3: Market Sentiment */}
|
||||
{/* ============================================ */}
|
||||
<div className="rounded-lg overflow-hidden" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="px-3 py-2 flex items-center gap-2" style={{ background: '#1E2329', borderBottom: '1px solid #2B3139' }}>
|
||||
<TrendingUp className="w-4 h-4" style={{ color: '#22c55e' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{ts(indicator.marketSentiment, language)}</span>
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>- {ts(indicator.marketSentimentDesc, language)}</span>
|
||||
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
||||
<Clock className="h-4 w-4 text-amber-300" />
|
||||
{t(language, '交易所上下文', 'Exchange context')}
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{marketContextCards.map(({ key, label, hint, color }) => {
|
||||
const enabled = Boolean(config[key])
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className="p-2.5 rounded-lg transition-all"
|
||||
style={{
|
||||
background: config[key as keyof IndicatorConfig] ? `${color}08` : 'transparent',
|
||||
border: `1px solid ${config[key as keyof IndicatorConfig] ? `${color}30` : '#2B3139'}`,
|
||||
}}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => toggleBool(key)}
|
||||
className={`rounded-xl border p-3 text-left transition-all ${enabled ? 'bg-white/[0.04]' : 'bg-transparent hover:bg-white/[0.03]'}`}
|
||||
style={{ borderColor: enabled ? `${color}66` : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: color }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{ts(indicator[label as keyof typeof indicator], language)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config[key as keyof IndicatorConfig] as boolean || false}
|
||||
onChange={(e) => !disabled && onChange({ ...config, [key]: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px]" style={{ color: '#5E6673' }}>{ts(indicator[desc as keyof typeof indicator], language)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-nofx-text">{label}</div>
|
||||
<div className="mt-1 text-[11px] text-nofx-text-muted">{hint}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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<string, unknown>
|
||||
} | 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 && (
|
||||
<PromptSectionsEditor
|
||||
config={currentAIConfig.prompt_sections}
|
||||
onChange={(promptSections) =>
|
||||
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 && (
|
||||
<div>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{tr('customPromptDesc')}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs leading-5" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '写策略很简单:点一句话模板,或直接写一句你的交易想法。'
|
||||
: 'Strategy writing is simple: click a one-line template or type one trading idea.'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{(language === 'zh' ? strategyPromptTemplates : strategyPromptTemplatesEn).map((template) => (
|
||||
<button
|
||||
key={template}
|
||||
type="button"
|
||||
disabled={selectedStrategy?.is_default}
|
||||
onClick={() => updateAIConfig('custom_prompt', template)}
|
||||
className="rounded-lg px-2 py-2 text-[11px] font-semibold transition hover:scale-[1.02] disabled:opacity-50"
|
||||
style={{ background: 'rgba(96,165,250,0.12)', border: '1px solid rgba(96,165,250,0.28)', color: '#BFDBFE' }}
|
||||
>
|
||||
{template}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={currentAIConfig.custom_prompt || ''}
|
||||
onChange={(e) => updateAIConfig('custom_prompt', e.target.value)}
|
||||
disabled={selectedStrategy?.is_default}
|
||||
placeholder={tr('customPromptPlaceholder')}
|
||||
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
|
||||
placeholder={language === 'zh' ? '例如:只做强趋势突破;避开财报/重大新闻;优先选择成交额高、波动清晰的标的。' : 'Example: trade only strong trend breakouts; avoid major news; prefer high-volume clean setups.'}
|
||||
className="w-full h-36 px-3 py-2 rounded-lg resize-none font-mono text-xs"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
@@ -1195,15 +1246,6 @@ export function StrategyStudioPage() {
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchPromptPreview}
|
||||
disabled={isLoadingPrompt || !editingConfig}
|
||||
@@ -1220,30 +1262,6 @@ export function StrategyStudioPage() {
|
||||
|
||||
{promptPreview ? (
|
||||
<>
|
||||
{/* Config Summary */}
|
||||
<div className="p-2 rounded-lg bg-nofx-bg border border-nofx-gold/20">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Code className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-xs font-medium text-purple-500">
|
||||
Config
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(promptPreview.config_summary || {}).map(
|
||||
([key, value]) => (
|
||||
<div key={key}>
|
||||
<div className="text-nofx-text-muted">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div className="text-nofx-text">
|
||||
{String(value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -1303,15 +1321,6 @@ export function StrategyStudioPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedVariant}
|
||||
onChange={(e) => setSelectedVariant(e.target.value)}
|
||||
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||
>
|
||||
<option value="balanced">{tr('balanced')}</option>
|
||||
<option value="aggressive">{tr('aggressive')}</option>
|
||||
<option value="conservative">{tr('conservative')}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={runAiTest}
|
||||
disabled={
|
||||
|
||||
Reference in New Issue
Block a user