From fcaabea6cb3be9ddbf2b25ae936b7728905a1f9f Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 23 Jan 2026 20:16:30 +0800 Subject: [PATCH] feat: add oi_low coin source for short opportunities - Add GetOILowPositions/GetOILowSymbols in oi.go - Add UseOILow/OILowLimit config fields - Add oi_low case in GetCandidateCoins - Support oi_low in mixed mode - Update source tag formatting --- kernel/engine.go | 84 +++++++++++++++++++++++++++++++++++++++++-- provider/nofxos/oi.go | 25 +++++++++++++ store/strategy.go | 8 +++-- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/kernel/engine.go b/kernel/engine.go index 35d450a5..8a937865 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -470,6 +470,26 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { // 空列表是正常情况,直接返回 return e.filterExcludedCoins(coins), nil + case "oi_low": + // 持仓减少榜,适合做空 + if !coinSource.UseOILow { + logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins") + for _, symbol := range coinSource.StaticCoins { + symbol = market.Normalize(symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"static"}, + }) + } + return e.filterExcludedCoins(candidates), nil + } + coins, err := e.getOILowCoins(coinSource.OILowLimit) + if err != nil { + return nil, err + } + // 空列表是正常情况,直接返回 + return e.filterExcludedCoins(coins), nil + case "mixed": if coinSource.UseAI500 { poolCoins, err := e.getAI500Coins(coinSource.AI500Limit) @@ -493,6 +513,17 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { } } + if coinSource.UseOILow { + oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit) + if err != nil { + logger.Infof("⚠️ Failed to get OI Low: %v", err) + } else { + for _, coin := range oiLowCoins { + symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low") + } + } + } + for _, symbol := range coinSource.StaticCoins { symbol = market.Normalize(symbol) if _, exists := symbolSources[symbol]; !exists { @@ -585,6 +616,30 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { return candidates, nil } +func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) { + if limit <= 0 { + limit = 20 + } + + positions, err := e.nofxosClient.GetOILowPositions() + if err != nil { + return nil, err + } + + var candidates []CandidateCoin + for i, pos := range positions { + if i >= limit { + break + } + symbol := market.Normalize(pos.Symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"oi_low"}, + }) + } + return candidates, nil +} + // ============================================================================ // External & Quant Data // ============================================================================ @@ -1291,13 +1346,38 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { if len(sources) > 1 { - return " (AI500+OI_Top dual signal)" + // 多信号源组合 + hasAI500 := false + hasOITop := false + hasOILow := false + for _, s := range sources { + switch s { + case "ai500": + hasAI500 = true + case "oi_top": + hasOITop = true + case "oi_low": + hasOILow = true + } + } + if hasAI500 && hasOITop { + return " (AI500+OI_Top dual signal)" + } + if hasAI500 && hasOILow { + return " (AI500+OI_Low dual signal)" + } + if hasOITop && hasOILow { + return " (OI_Top+OI_Low)" + } + return " (Multiple sources)" } else if len(sources) == 1 { switch sources[0] { case "ai500": return " (AI500)" case "oi_top": - return " (OI_Top position growth)" + return " (OI_Top 持仓增加)" + case "oi_low": + return " (OI_Low 持仓减少)" case "static": return " (Manual selection)" } diff --git a/provider/nofxos/oi.go b/provider/nofxos/oi.go index c6b5b778..a1fe7408 100644 --- a/provider/nofxos/oi.go +++ b/provider/nofxos/oi.go @@ -129,6 +129,31 @@ func (c *Client) GetOITopSymbols() ([]string, error) { return symbols, nil } +// GetOILowPositions retrieves OI decrease positions (for short opportunities) +func (c *Client) GetOILowPositions() ([]OIPosition, error) { + data, err := c.GetOIRanking("1h", 20) + if err != nil { + return nil, err + } + return data.LowPositions, nil +} + +// GetOILowSymbols retrieves OI low coin symbol list +func (c *Client) GetOILowSymbols() ([]string, error) { + positions, err := c.GetOILowPositions() + if err != nil { + return nil, err + } + + var symbols []string + for _, pos := range positions { + symbol := NormalizeSymbol(pos.Symbol) + symbols = append(symbols, symbol) + } + + return symbols, nil +} + // FormatOIRankingForAI formats OI ranking data for AI consumption func FormatOIRankingForAI(data *OIRankingData, lang Language) string { if data == nil { diff --git a/store/strategy.go b/store/strategy.go index be009851..8f7d86ee 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -97,7 +97,7 @@ type PromptSectionsConfig struct { // CoinSourceConfig coin source configuration type CoinSourceConfig struct { - // source type: "static" | "ai500" | "oi_top" | "mixed" + // source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed" SourceType string `json:"source_type"` // static coin list (used when source_type = "static") StaticCoins []string `json:"static_coins,omitempty"` @@ -107,10 +107,14 @@ type CoinSourceConfig struct { UseAI500 bool `json:"use_ai500"` // AI500 coin pool maximum count AI500Limit int `json:"ai500_limit,omitempty"` - // whether to use OI Top + // whether to use OI Top (持仓增加榜,适合做多) UseOITop bool `json:"use_oi_top"` // OI Top maximum count OITopLimit int `json:"oi_top_limit,omitempty"` + // whether to use OI Low (持仓减少榜,适合做空) + UseOILow bool `json:"use_oi_low"` + // OI Low maximum count + OILowLimit int `json:"oi_low_limit,omitempty"` // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig }