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:
tinklefund
2026-05-25 01:25:05 +08:00
parent c7c003cc3c
commit 5bdffee3b0
8 changed files with 903 additions and 1114 deletions

View File

@@ -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 == "" {

View File

@@ -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
// ============================================================================

View File

@@ -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,
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,22 +952,19 @@ 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,
// 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,
// NetFlow ranking data
EnableNetFlowRanking: true,
EnableNetFlowRanking: false,
NetFlowRankingDuration: "1h",
NetFlowRankingLimit: 10,
// Price ranking data
EnablePriceRanking: true,
EnablePriceRanking: false,
PriceRankingDuration: "1h,4h,24h",
PriceRankingLimit: 10,
},
@@ -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

View 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)
}
}

View File

@@ -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 rankDirections = [
{ value: 'gainers', labelZh: '涨幅榜', labelEn: 'Gainers' },
{ value: 'losers', labelZh: '跌幅榜', labelEn: 'Losers' },
{ value: 'volume', labelZh: '成交额榜', labelEn: 'Volume' },
] as const
const SELECTED_MARKET_LIMIT = 10
const RANK_LIMIT = 10
const DEFAULT_RANK_LIMIT = 5
const CATALOG_DISPLAY_LIMIT = 120
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)}`
}
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 MAX_STATIC_COINS = 10
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 handleAddCoin = () => {
if (!newCoin.trim()) return
const currentCoins = config.static_coins || []
if (currentCoins.length >= MAX_STATIC_COINS) {
showToast(language === 'zh' ? `最多添加 ${MAX_STATIC_COINS} 个币种` : `Maximum ${MAX_STATIC_COINS} coins allowed`)
return
loadSymbols()
return () => {
cancelled = true
}
}, [])
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`
}
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 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>
<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="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' && (
{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>
<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"
<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"
>
{coin}
{!disabled && (
{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
onClick={() => handleRemoveCoin(coin)}
className="ml-1 hover:text-red-400 transition-colors"
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'
}`}
>
<X className="w-3 h-3" />
{cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat}
</button>
)}
</span>
))}
</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 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 className="space-y-4">
<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 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>
<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}
<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
onClick={() => handleRemoveExcludedCoin(coin)}
className="ml-1 hover:text-white transition-colors"
>
<X className="w-3 h-3" />
<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>
)}
</div>
</div>
<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}
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>
<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>
))}
{(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"
/>
</div>
{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>}
{!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
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"
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]'
}`}
>
<Ban className="w-4 h-4" />
{ts(coinSource.addExcludedCoin, language)}
<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>
)}
</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 })
}
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>
)}
{/* 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>
</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>
{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"
/>
</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>
)
}

View File

@@ -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',
}
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
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
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,
})
}
// Toggle timeframe selection
const toggleTimeframe = (tf: string) => {
if (disabled) return
const current = [...selectedTimeframes]
const index = current.indexOf(tf)
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,
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,
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,
},
})
}
}
// Set primary timeframe
const setPrimaryTimeframe = (tf: string) => {
if (disabled) return
onChange({
...config,
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 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>
<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>
<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)}
<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>
{/* 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>
</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>
<input
type="checkbox"
checked={true}
disabled={true}
className="w-5 h-5 rounded accent-yellow-500 cursor-not-allowed"
/>
</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>
<div className="flex items-center gap-2 text-xs text-nofx-text-muted">
{t(language, '根数', 'Bars')}
<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' }}
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>
<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
<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}
onClick={() => toggleTimeframe(tf.value)}
onDoubleClick={() => setPrimaryTimeframe(tf.value)}
type="button"
disabled={disabled}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
isSelected ? '' : 'opacity-40 hover:opacity-70'
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'
}`}
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}
title={primary ? 'Primary timeframe' : 'Double click selected item to make primary'}
>
{tf.label}
{isPrimary && <span className="ml-0.5 text-[8px]"></span>}
{tf.label}{primary && ' ★'}
</button>
)
})}
</div>
</div>
))}
</div>
</div>
<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="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}
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 text-sm font-medium text-nofx-text">
<span>{label}</span>
<span className="h-2 w-2 rounded-full" style={{ background: enabled ? color : '#5E6673' }} />
</div>
<div className="mt-1 text-[11px] text-nofx-text-muted">{hint}</div>
</button>
)
})}
</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">
<Clock className="h-4 w-4 text-amber-300" />
{t(language, '交易所上下文', 'Exchange context')}
</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="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] 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>
</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>
<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
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'}`,
}}
>
<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>

View File

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

View File

@@ -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={