diff --git a/decision/engine.go b/decision/engine.go index bafa320b..ce29222f 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -119,7 +119,7 @@ type Context struct { MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` QuantDataMap map[string]*QuantData `json:"-"` - OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data + OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data BTCETHLeverage int `json:"-"` AltcoinLeverage int `json:"-"` Timeframes []string `json:"-"` @@ -401,7 +401,8 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { Sources: []string{"static"}, }) } - return candidates, nil + + return e.filterExcludedCoins(candidates), nil case "coinpool": // 检查 use_coin_pool 标志,如果为 false 则回退到静态币种 @@ -414,9 +415,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { Sources: []string{"static"}, }) } - return candidates, nil + return e.filterExcludedCoins(candidates), nil } - return e.getCoinPoolCoins(coinSource.CoinPoolLimit) + coins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) + if err != nil { + return nil, err + } + return e.filterExcludedCoins(coins), nil case "oi_top": // 检查 use_oi_top 标志,如果为 false 则回退到静态币种 @@ -429,9 +434,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { Sources: []string{"static"}, }) } - return candidates, nil + return e.filterExcludedCoins(candidates), nil } - return e.getOITopCoins(coinSource.OITopLimit) + coins, err := e.getOITopCoins(coinSource.OITopLimit) + if err != nil { + return nil, err + } + return e.filterExcludedCoins(coins), nil case "mixed": if coinSource.UseCoinPool { @@ -471,13 +480,39 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { Sources: sources, }) } - return candidates, nil + return e.filterExcludedCoins(candidates), nil default: return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType) } } +// filterExcludedCoins removes excluded coins from the candidates list +func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []CandidateCoin { + if len(e.config.CoinSource.ExcludedCoins) == 0 { + return candidates + } + + // Build excluded set for O(1) lookup + excluded := make(map[string]bool) + for _, coin := range e.config.CoinSource.ExcludedCoins { + normalized := market.Normalize(coin) + excluded[normalized] = true + } + + // Filter out excluded coins + filtered := make([]CandidateCoin, 0, len(candidates)) + for _, c := range candidates { + if !excluded[c.Symbol] { + filtered = append(filtered, c) + } else { + logger.Infof("🚫 Excluded coin: %s", c.Symbol) + } + } + + return filtered +} + func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) { if limit <= 0 { limit = 30 diff --git a/store/strategy.go b/store/strategy.go index d7c62063..3551ae1a 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -62,6 +62,8 @@ type CoinSourceConfig struct { SourceType string `json:"source_type"` // static coin list (used when source_type = "static") StaticCoins []string `json:"static_coins,omitempty"` + // excluded coins list (filtered out from all sources) + ExcludedCoins []string `json:"excluded_coins,omitempty"` // whether to use AI500 coin pool UseCoinPool bool `json:"use_coin_pool"` // AI500 coin pool maximum count diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 7ba59bac..12cd8ffd 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Plus, X, Database, TrendingUp, List, Link, AlertCircle } from 'lucide-react' +import { Plus, X, Database, TrendingUp, List, Link, AlertCircle, Ban } from 'lucide-react' import type { CoinSourceConfig } from '../../types' // Default API URLs for data sources @@ -20,6 +20,7 @@ export function CoinSourceEditor({ language, }: CoinSourceEditorProps) { const [newCoin, setNewCoin] = useState('') + const [newExcludedCoin, setNewExcludedCoin] = useState('') const t = (key: string) => { const translations: Record> = { @@ -54,6 +55,9 @@ export function CoinSourceEditor({ apiUrlRequired: { zh: '需要填写 API URL 才能获取数据', en: 'API URL required to fetch data' }, dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' }, fillDefault: { zh: '填入默认', en: 'Fill Default' }, + excludedCoins: { zh: '排除币种', en: 'Excluded Coins' }, + excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' }, + addExcludedCoin: { zh: '添加排除', en: 'Add Excluded' }, } return translations[key]?.[language] || key } @@ -115,6 +119,36 @@ export function CoinSourceEditor({ }) } + 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), + }) + } + return (
{/* Source Type Selector */} @@ -209,6 +243,68 @@ export function CoinSourceEditor({
)} + {/* Excluded Coins */} +
+
+ + +
+

+ {t('excludedCoinsDesc')} +

+
+ {(config.excluded_coins || []).map((coin) => ( + + {coin} + {!disabled && ( + + )} + + ))} + {(config.excluded_coins || []).length === 0 && ( + + {language === 'zh' ? '无' : 'None'} + + )} +
+ {!disabled && ( +
+ setNewExcludedCoin(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddExcludedCoin()} + placeholder="BTC, ETH, DOGE..." + className="flex-1 px-4 py-2 rounded-lg text-sm" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + +
+ )} +
+ {/* Coin Pool Options */} {(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
@@ -400,6 +496,7 @@ export function CoinSourceEditor({ )}
)} + ) } diff --git a/web/src/types.ts b/web/src/types.ts index 23142291..598d3c53 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -472,6 +472,7 @@ export interface StrategyConfig { export interface CoinSourceConfig { source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed'; static_coins?: string[]; + excluded_coins?: string[]; // 排除的币种列表 use_coin_pool: boolean; coin_pool_limit?: number; coin_pool_api_url?: string; // AI500 币种池 API URL