mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 10:24:38 +08:00
Compare commits
40 Commits
fcc0267a46
...
ai-grid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b56a98bf | ||
|
|
e5f69bfea6 | ||
|
|
e198498f3a | ||
|
|
993db33466 | ||
|
|
7f24a90851 | ||
|
|
19698529b8 | ||
|
|
35fcf17df4 | ||
|
|
2b1012b85b | ||
|
|
826276f58c | ||
|
|
587efba52c | ||
|
|
4642671e77 | ||
|
|
bd8cc9c176 | ||
|
|
7d7493b576 | ||
|
|
cbe753b9e6 | ||
|
|
5c79aa451e | ||
|
|
0a2c62885b | ||
|
|
ac25dd334e | ||
|
|
f4cdf2e532 | ||
|
|
f6411f05ba | ||
|
|
38be361eca | ||
|
|
584bfae699 | ||
|
|
73789f7fb7 | ||
|
|
65f333e73c | ||
|
|
1454ad3112 | ||
|
|
ec81384b7a | ||
|
|
c161632e2b | ||
|
|
8ef6045f9d | ||
|
|
d7d9dc5c42 | ||
|
|
90509ae783 | ||
|
|
937527281e | ||
|
|
2bc45827f3 | ||
|
|
68e8a6e4b0 | ||
|
|
aa7aa94275 | ||
|
|
13189fa3aa | ||
|
|
33cf09e7fe | ||
|
|
ef91bec2dd | ||
|
|
2fcbdbab36 | ||
|
|
1786f0ff53 | ||
|
|
1b47249d57 | ||
|
|
5fb26c17dc |
20
README.md
20
README.md
@@ -488,26 +488,6 @@ All contributions are tracked on GitHub. When NOFX generates revenue, contributo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sponsors
|
|
||||||
|
|
||||||
Thanks to all our sponsors!
|
|
||||||
|
|
||||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
|
|
||||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
|
|
||||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
|
|
||||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
|
|
||||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
|
|
||||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
|
|
||||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
|
|
||||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
|
|
||||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
|
|
||||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
|
|
||||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
|
|
||||||
|
|
||||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||||
|
|||||||
@@ -447,7 +447,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 空列表是正常情况,直接返回
|
|
||||||
return e.filterExcludedCoins(coins), nil
|
return e.filterExcludedCoins(coins), nil
|
||||||
|
|
||||||
case "oi_top":
|
case "oi_top":
|
||||||
@@ -467,27 +466,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 空列表是正常情况,直接返回
|
|
||||||
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
|
return e.filterExcludedCoins(coins), nil
|
||||||
|
|
||||||
case "mixed":
|
case "mixed":
|
||||||
@@ -513,17 +491,6 @@ 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 {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
if _, exists := symbolSources[symbol]; !exists {
|
if _, exists := symbolSources[symbol]; !exists {
|
||||||
@@ -594,7 +561,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
|||||||
|
|
||||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 20
|
||||||
}
|
}
|
||||||
|
|
||||||
positions, err := e.nofxosClient.GetOITopPositions()
|
positions, err := e.nofxosClient.GetOITopPositions()
|
||||||
@@ -616,30 +583,6 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
|||||||
return candidates, nil
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
// External & Quant Data
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1346,38 +1289,13 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
|||||||
|
|
||||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||||
if len(sources) > 1 {
|
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 {
|
} else if len(sources) == 1 {
|
||||||
switch sources[0] {
|
switch sources[0] {
|
||||||
case "ai500":
|
case "ai500":
|
||||||
return " (AI500)"
|
return " (AI500)"
|
||||||
case "oi_top":
|
case "oi_top":
|
||||||
return " (OI_Top 持仓增加)"
|
return " (OI_Top position growth)"
|
||||||
case "oi_low":
|
|
||||||
return " (OI_Low 持仓减少)"
|
|
||||||
case "static":
|
case "static":
|
||||||
return " (Manual selection)"
|
return " (Manual selection)"
|
||||||
}
|
}
|
||||||
@@ -1849,8 +1767,8 @@ func compactArrayOpen(s string) string {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||||
for i := range decisions {
|
for i, decision := range decisions {
|
||||||
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,10 +73,8 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
|
|||||||
return nil, fmt.Errorf("API returned failure status")
|
return nil, fmt.Errorf("API returned failure status")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空列表是正常情况,不是错误
|
|
||||||
if len(response.Data.Coins) == 0 {
|
if len(response.Data.Coins) == 0 {
|
||||||
log.Printf("ℹ️ AI500 returned empty coin list (no coins meet criteria currently)")
|
return nil, fmt.Errorf("coin list is empty")
|
||||||
return []CoinData{}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set IsAvailable flag
|
// Set IsAvailable flag
|
||||||
|
|||||||
@@ -106,11 +106,11 @@ func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosit
|
|||||||
|
|
||||||
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
||||||
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
||||||
positions, _, err := c.fetchOIRanking("top", "1h", 20)
|
data, err := c.GetOIRanking("1h", 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return positions, nil
|
return data.TopPositions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOITopSymbols retrieves OI top coin symbol list
|
// GetOITopSymbols retrieves OI top coin symbol list
|
||||||
@@ -129,31 +129,6 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
|
|||||||
return symbols, nil
|
return symbols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
|
|
||||||
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
|
|
||||||
positions, _, err := c.fetchOIRanking("low", "1h", 20)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return positions, 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
|
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||||
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
|
|||||||
@@ -158,19 +158,16 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
|||||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||||
newFee := pos.Fee + addFee
|
newFee := pos.Fee + addFee
|
||||||
nowMs := time.Now().UTC().UnixMilli()
|
|
||||||
|
|
||||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"quantity": newQty,
|
"quantity": newQty,
|
||||||
"entry_quantity": newEntryQty,
|
"entry_quantity": newEntryQty,
|
||||||
"entry_price": newEntryPrice,
|
"entry_price": newEntryPrice,
|
||||||
"fee": newFee,
|
"fee": newFee,
|
||||||
"updated_at": nowMs,
|
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReducePositionQuantity reduces position quantity for partial close
|
// ReducePositionQuantity reduces position quantity for partial close
|
||||||
// If quantity reaches 0 (or near 0), automatically closes the position
|
|
||||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||||
var pos TraderPosition
|
var pos TraderPosition
|
||||||
if err := s.db.First(&pos, id).Error; err != nil {
|
if err := s.db.First(&pos, id).Error; err != nil {
|
||||||
@@ -190,40 +187,19 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
|||||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
nowMs := time.Now().UTC().UnixMilli()
|
|
||||||
|
|
||||||
// Check if position should be fully closed (quantity reduced to ~0)
|
|
||||||
const QUANTITY_TOLERANCE = 0.0001
|
|
||||||
if newQty <= QUANTITY_TOLERANCE {
|
|
||||||
// Auto-close: set status to CLOSED
|
|
||||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
||||||
"quantity": 0,
|
|
||||||
"fee": newFee,
|
|
||||||
"exit_price": newExitPrice,
|
|
||||||
"realized_pnl": newPnL,
|
|
||||||
"status": "CLOSED",
|
|
||||||
"exit_time": nowMs,
|
|
||||||
"close_reason": "sync",
|
|
||||||
"updated_at": nowMs,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"quantity": newQty,
|
"quantity": newQty,
|
||||||
"fee": newFee,
|
"fee": newFee,
|
||||||
"exit_price": newExitPrice,
|
"exit_price": newExitPrice,
|
||||||
"realized_pnl": newPnL,
|
"realized_pnl": newPnL,
|
||||||
"updated_at": nowMs,
|
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
||||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
||||||
nowMs := time.Now().UTC().UnixMilli()
|
|
||||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exchange_id": exchangeID,
|
"exchange_id": exchangeID,
|
||||||
"exchange_type": exchangeType,
|
"exchange_type": exchangeType,
|
||||||
"updated_at": nowMs,
|
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ type PromptSectionsConfig struct {
|
|||||||
|
|
||||||
// CoinSourceConfig coin source configuration
|
// CoinSourceConfig coin source configuration
|
||||||
type CoinSourceConfig struct {
|
type CoinSourceConfig struct {
|
||||||
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
// source type: "static" | "ai500" | "oi_top" | "mixed"
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
// static coin list (used when source_type = "static")
|
// static coin list (used when source_type = "static")
|
||||||
StaticCoins []string `json:"static_coins,omitempty"`
|
StaticCoins []string `json:"static_coins,omitempty"`
|
||||||
@@ -107,14 +107,10 @@ type CoinSourceConfig struct {
|
|||||||
UseAI500 bool `json:"use_ai500"`
|
UseAI500 bool `json:"use_ai500"`
|
||||||
// AI500 coin pool maximum count
|
// AI500 coin pool maximum count
|
||||||
AI500Limit int `json:"ai500_limit,omitempty"`
|
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||||
// whether to use OI Top (持仓增加榜,适合做多)
|
// whether to use OI Top
|
||||||
UseOITop bool `json:"use_oi_top"`
|
UseOITop bool `json:"use_oi_top"`
|
||||||
// OI Top maximum count
|
// OI Top maximum count
|
||||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
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
|
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,9 +248,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
UseAI500: true,
|
UseAI500: true,
|
||||||
AI500Limit: 10,
|
AI500Limit: 10,
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 10,
|
OITopLimit: 20,
|
||||||
UseOILow: false,
|
|
||||||
OILowLimit: 10,
|
|
||||||
},
|
},
|
||||||
Indicators: IndicatorConfig{
|
Indicators: IndicatorConfig{
|
||||||
Klines: KlineConfig{
|
Klines: KlineConfig{
|
||||||
|
|||||||
@@ -534,12 +534,6 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
return fmt.Errorf("failed to build trading context: %w", err)
|
return fmt.Errorf("failed to build trading context: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有候选币种,友好提示并跳过本周期
|
|
||||||
if len(ctx.CandidateCoins) == 0 {
|
|
||||||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||||
at.saveEquitySnapshot(ctx)
|
at.saveEquitySnapshot(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
|
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
|
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||||
|
syncStartTimeMs := nowMs
|
||||||
|
|
||||||
|
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
|
||||||
|
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||||
@@ -96,17 +100,18 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
symbolMap[s] = true
|
symbolMap[s] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
|
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
|
||||||
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||||
// IMPORTANT: Must run always, not just when symbolMap is empty,
|
if len(symbolMap) == 0 {
|
||||||
// because a position might be fully closed (no active position) but have PnL
|
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
|
||||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||||
for _, s := range pnlSymbols {
|
for _, s := range pnlSymbols {
|
||||||
symbolMap[s] = true
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +122,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
|
|
||||||
if len(changedSymbols) == 0 {
|
if len(changedSymbols) == 0 {
|
||||||
logger.Infof("📭 No symbols with new trades to sync")
|
logger.Infof("📭 No symbols with new trades to sync")
|
||||||
// DON'T update lastSyncTime to current time here!
|
// Update last sync time even if no changes
|
||||||
// Keep using the last actual trade time from DB to avoid creating gaps
|
binanceSyncStateMutex.Lock()
|
||||||
// The lastSyncTimeMs from DB already has +1000ms buffer added
|
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||||
|
binanceSyncStateMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +158,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
|
|
||||||
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
||||||
|
|
||||||
|
// Only update last sync time if ALL symbols were successfully queried
|
||||||
|
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||||
|
if len(failedSymbols) == 0 {
|
||||||
|
binanceSyncStateMutex.Lock()
|
||||||
|
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||||
|
binanceSyncStateMutex.Unlock()
|
||||||
|
} else {
|
||||||
|
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||||
|
}
|
||||||
|
|
||||||
if len(allTrades) == 0 {
|
if len(allTrades) == 0 {
|
||||||
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
|
|
||||||
// Don't update lastSyncTime, keep using DB value
|
|
||||||
if len(failedSymbols) > 0 {
|
|
||||||
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,12 +182,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
posBuilder := store.NewPositionBuilder(positionStore)
|
posBuilder := store.NewPositionBuilder(positionStore)
|
||||||
syncedCount := 0
|
syncedCount := 0
|
||||||
|
|
||||||
skippedCount := 0
|
|
||||||
for _, trade := range allTrades {
|
for _, trade := range allTrades {
|
||||||
// Check if trade already exists
|
// Check if trade already exists
|
||||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||||
if err == nil && existing != nil {
|
if err == nil && existing != nil {
|
||||||
skippedCount++
|
|
||||||
continue // Trade already exists, skip
|
continue // Trade already exists, skip
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,21 +280,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
trade.Time.UTC().Format("01-02 15:04:05"))
|
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update lastSyncTime to the LATEST trade time (not current time!)
|
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||||
// This ensures next sync starts from where we left off, not from "now"
|
|
||||||
// allTrades is already sorted by time ASC, so last element is the latest
|
|
||||||
if len(allTrades) > 0 && len(failedSymbols) == 0 {
|
|
||||||
latestTradeTimeMs := allTrades[len(allTrades)-1].Time.UTC().UnixMilli()
|
|
||||||
binanceSyncStateMutex.Lock()
|
|
||||||
binanceSyncState[exchangeID] = latestTradeTimeMs
|
|
||||||
binanceSyncStateMutex.Unlock()
|
|
||||||
logger.Infof("📅 Updated lastSyncTime to latest trade: %s (UTC)",
|
|
||||||
time.UnixMilli(latestTradeTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
|
||||||
} else if len(failedSymbols) > 0 {
|
|
||||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("✅ Binance order sync completed: %d new trades synced, %d skipped (already exist)", syncedCount, skippedCount)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
|
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
|
||||||
import type { CoinSourceConfig } from '../../types'
|
import type { CoinSourceConfig } from '../../types'
|
||||||
|
|
||||||
interface CoinSourceEditorProps {
|
interface CoinSourceEditorProps {
|
||||||
@@ -23,38 +23,27 @@ export function CoinSourceEditor({
|
|||||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||||
static: { zh: '静态列表', en: 'Static List' },
|
static: { zh: '静态列表', en: 'Static List' },
|
||||||
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
|
||||||
oi_top: { zh: 'OI 持仓增加', en: 'OI Increase' },
|
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||||
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
|
|
||||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||||
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
|
||||||
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
ai500Limit: { zh: '数量上限', en: 'Limit' },
|
||||||
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
|
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||||
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
oiTopLimit: { zh: '数量上限', en: 'Limit' },
|
||||||
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease' },
|
|
||||||
oiLowLimit: { zh: '数量上限', en: 'Limit' },
|
|
||||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||||
ai500Desc: {
|
ai500Desc: {
|
||||||
zh: '使用 AI500 智能筛选的热门币种',
|
zh: '使用 AI500 智能筛选的热门币种',
|
||||||
en: 'Use AI500 smart-filtered popular coins',
|
en: 'Use AI500 smart-filtered popular coins',
|
||||||
},
|
},
|
||||||
oiTopDesc: {
|
oiTopDesc: {
|
||||||
zh: '持仓增加榜,适合做多',
|
zh: '使用持仓量增长最快的币种',
|
||||||
en: 'OI increase ranking, for long',
|
en: 'Use coins with fastest OI growth',
|
||||||
},
|
|
||||||
oi_lowDesc: {
|
|
||||||
zh: '持仓减少榜,适合做空',
|
|
||||||
en: 'OI decrease ranking, for short',
|
|
||||||
},
|
},
|
||||||
mixedDesc: {
|
mixedDesc: {
|
||||||
zh: '组合多种数据源',
|
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||||
en: 'Combine multiple sources',
|
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||||
},
|
},
|
||||||
mixedConfig: { zh: '组合数据源配置', en: 'Combined Sources Configuration' },
|
|
||||||
mixedSummary: { zh: '已选组合', en: 'Selected Sources' },
|
|
||||||
maxCoins: { zh: '最多', en: 'Up to' },
|
|
||||||
coins: { zh: '个币种', en: 'coins' },
|
|
||||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||||
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
|
||||||
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
excludedCoinsDesc: { zh: '这些币种将从所有数据源中排除,不会被交易', en: 'These coins will be excluded from all sources and will not be traded' },
|
||||||
@@ -68,35 +57,9 @@ export function CoinSourceEditor({
|
|||||||
{ value: 'static', icon: List, color: '#848E9C' },
|
{ value: 'static', icon: List, color: '#848E9C' },
|
||||||
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
{ value: 'ai500', icon: Database, color: '#F0B90B' },
|
||||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||||
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
|
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||||
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Calculate mixed mode summary
|
|
||||||
const getMixedSummary = () => {
|
|
||||||
const sources: string[] = []
|
|
||||||
let totalLimit = 0
|
|
||||||
|
|
||||||
if (config.use_ai500) {
|
|
||||||
sources.push(`AI500(${config.ai500_limit || 10})`)
|
|
||||||
totalLimit += config.ai500_limit || 10
|
|
||||||
}
|
|
||||||
if (config.use_oi_top) {
|
|
||||||
sources.push(`${language === 'zh' ? 'OI增' : 'OI↑'}(${config.oi_top_limit || 10})`)
|
|
||||||
totalLimit += config.oi_top_limit || 10
|
|
||||||
}
|
|
||||||
if (config.use_oi_low) {
|
|
||||||
sources.push(`${language === 'zh' ? 'OI减' : 'OI↓'}(${config.oi_low_limit || 10})`)
|
|
||||||
totalLimit += config.oi_low_limit || 10
|
|
||||||
}
|
|
||||||
if ((config.static_coins || []).length > 0) {
|
|
||||||
sources.push(`${language === 'zh' ? '自定义' : 'Custom'}(${config.static_coins?.length || 0})`)
|
|
||||||
totalLimit += config.static_coins?.length || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sources, totalLimit }
|
|
||||||
}
|
|
||||||
|
|
||||||
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
|
// xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix
|
||||||
const xyzDexAssets = new Set([
|
const xyzDexAssets = new Set([
|
||||||
// Stocks
|
// Stocks
|
||||||
@@ -193,7 +156,7 @@ export function CoinSourceEditor({
|
|||||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||||
{t('sourceType')}
|
{t('sourceType')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
@@ -219,8 +182,8 @@ export function CoinSourceEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Static Coins - only for static mode */}
|
{/* Static Coins */}
|
||||||
{config.source_type === 'static' && (
|
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
<label className="block text-sm font-medium mb-3 text-nofx-text">
|
||||||
{t('staticCoins')}
|
{t('staticCoins')}
|
||||||
@@ -320,8 +283,8 @@ export function CoinSourceEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI500 Options - only for ai500 mode */}
|
{/* AI500 Options */}
|
||||||
{config.source_type === 'ai500' && (
|
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
|
||||||
>
|
>
|
||||||
@@ -377,8 +340,8 @@ export function CoinSourceEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* OI Top Options - only for oi_top mode */}
|
{/* OI Top Options */}
|
||||||
{config.source_type === 'oi_top' && (
|
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||||
<div
|
<div
|
||||||
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
|
||||||
>
|
>
|
||||||
@@ -386,7 +349,7 @@ export function CoinSourceEditor({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
||||||
<span className="text-sm font-medium text-nofx-text">
|
<span className="text-sm font-medium text-nofx-text">
|
||||||
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
|
OI Top {t('dataSourceConfig')}
|
||||||
</span>
|
</span>
|
||||||
<NofxOSBadge />
|
<NofxOSBadge />
|
||||||
</div>
|
</div>
|
||||||
@@ -412,10 +375,10 @@ export function CoinSourceEditor({
|
|||||||
{t('oiTopLimit')}:
|
{t('oiTopLimit')}:
|
||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
value={config.oi_top_limit || 10}
|
value={config.oi_top_limit || 20}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
!disabled &&
|
!disabled &&
|
||||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
||||||
@@ -433,306 +396,6 @@ export function CoinSourceEditor({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
|
||||||
OI {language === 'zh' ? '持仓减少榜' : 'Decrease'} {t('dataSourceConfig')}
|
|
||||||
</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">{t('useOILow')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{config.use_oi_low && (
|
|
||||||
<div className="flex items-center gap-3 pl-8">
|
|
||||||
<span className="text-sm text-nofx-text-muted">
|
|
||||||
{t('oiLowLimit')}:
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={config.oi_low_limit || 10}
|
|
||||||
onChange={(e) =>
|
|
||||||
!disabled &&
|
|
||||||
onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-3 py-1.5 rounded bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs pl-8 text-nofx-text-muted">
|
|
||||||
{t('nofxosNote')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mixed Mode - Unified Card Selector */}
|
|
||||||
{config.source_type === 'mixed' && (
|
|
||||||
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Shuffle className="w-4 h-4 text-blue-400" />
|
|
||||||
<span className="text-sm font-medium text-nofx-text">
|
|
||||||
{t('mixedConfig')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 4 Source Cards in 2x2 Grid */}
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
|
||||||
{/* AI500 Card */}
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
|
||||||
config.use_ai500
|
|
||||||
? 'bg-nofx-gold/10 border-nofx-gold/50'
|
|
||||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-gold/30'
|
|
||||||
}`}
|
|
||||||
onClick={() => !disabled && onChange({ ...config, use_ai500: !config.use_ai500 })}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={config.use_ai500}
|
|
||||||
onChange={(e) => !disabled && onChange({ ...config, use_ai500: e.target.checked })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-4 h-4 rounded accent-nofx-gold"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Database className="w-4 h-4 text-nofx-gold" />
|
|
||||||
<span className="text-sm font-medium text-nofx-text">AI500</span>
|
|
||||||
<NofxOSBadge />
|
|
||||||
</div>
|
|
||||||
{config.use_ai500 && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
|
||||||
<select
|
|
||||||
value={config.ai500_limit || 10}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, ai500_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OI Top Card */}
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
|
||||||
config.use_oi_top
|
|
||||||
? 'bg-nofx-success/10 border-nofx-success/50'
|
|
||||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-success/30'
|
|
||||||
}`}
|
|
||||||
onClick={() => !disabled && onChange({ ...config, use_oi_top: !config.use_oi_top })}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={config.use_oi_top}
|
|
||||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_top: e.target.checked })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-4 h-4 rounded accent-nofx-success"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<TrendingUp className="w-4 h-4 text-nofx-success" />
|
|
||||||
<span className="text-sm font-medium text-nofx-text">
|
|
||||||
{language === 'zh' ? 'OI 增加' : 'OI Increase'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
|
||||||
{language === 'zh' ? '适合做多' : 'For long'}
|
|
||||||
</p>
|
|
||||||
{config.use_oi_top && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
|
||||||
<select
|
|
||||||
value={config.oi_top_limit || 10}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OI Low Card */}
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
|
||||||
config.use_oi_low
|
|
||||||
? 'bg-nofx-danger/10 border-nofx-danger/50'
|
|
||||||
: 'bg-nofx-bg border-nofx-border hover:border-nofx-danger/30'
|
|
||||||
}`}
|
|
||||||
onClick={() => !disabled && onChange({ ...config, use_oi_low: !config.use_oi_low })}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={config.use_oi_low}
|
|
||||||
onChange={(e) => !disabled && onChange({ ...config, use_oi_low: e.target.checked })}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-4 h-4 rounded accent-red-500"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<TrendingDown className="w-4 h-4 text-nofx-danger" />
|
|
||||||
<span className="text-sm font-medium text-nofx-text">
|
|
||||||
{language === 'zh' ? 'OI 减少' : 'OI Decrease'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-nofx-text-muted pl-6 mb-1">
|
|
||||||
{language === 'zh' ? '适合做空' : 'For short'}
|
|
||||||
</p>
|
|
||||||
{config.use_oi_low && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 pl-6">
|
|
||||||
<span className="text-xs text-nofx-text-muted">Limit:</span>
|
|
||||||
<select
|
|
||||||
value={config.oi_low_limit || 10}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
!disabled && onChange({ ...config, oi_low_limit: parseInt(e.target.value) || 10 })
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20, 30, 50].map(n => (
|
|
||||||
<option key={n} value={n}>{n}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Static/Custom Card */}
|
|
||||||
<div
|
|
||||||
className={`p-3 rounded-lg border transition-all cursor-pointer ${
|
|
||||||
(config.static_coins || []).length > 0
|
|
||||||
? 'bg-gray-500/10 border-gray-500/50'
|
|
||||||
: 'bg-nofx-bg border-nofx-border hover:border-gray-500/30'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<List className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-sm font-medium text-nofx-text">
|
|
||||||
{language === 'zh' ? '自定义' : 'Custom'}
|
|
||||||
</span>
|
|
||||||
{(config.static_coins || []).length > 0 && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">
|
|
||||||
{config.static_coins?.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{(config.static_coins || []).slice(0, 3).map((coin) => (
|
|
||||||
<span
|
|
||||||
key={coin}
|
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-nofx-bg-lighter text-nofx-text"
|
|
||||||
>
|
|
||||||
{coin}
|
|
||||||
{!disabled && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleRemoveCoin(coin)
|
|
||||||
}}
|
|
||||||
className="hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-2.5 h-2.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{(config.static_coins || []).length > 3 && (
|
|
||||||
<span className="text-xs text-nofx-text-muted">
|
|
||||||
+{(config.static_coins?.length || 0) - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!disabled && (
|
|
||||||
<div className="flex gap-1 mt-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newCoin}
|
|
||||||
onChange={(e) => setNewCoin(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') handleAddCoin()
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
placeholder="BTC, ETH..."
|
|
||||||
className="flex-1 px-2 py-1 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleAddCoin()
|
|
||||||
}}
|
|
||||||
className="px-2 py-1 rounded text-xs bg-nofx-gold text-black hover:bg-yellow-500"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Summary */}
|
|
||||||
{(() => {
|
|
||||||
const { sources, totalLimit } = getMixedSummary()
|
|
||||||
if (sources.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div className="p-2 rounded bg-nofx-bg border border-nofx-border">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-nofx-text-muted">{t('mixedSummary')}:</span>
|
|
||||||
<span className="text-nofx-text font-medium">
|
|
||||||
{sources.join(' + ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-nofx-text-muted mt-1">
|
|
||||||
{t('maxCoins')} {totalLimit} {t('coins')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function GridConfigEditor({
|
|||||||
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
|
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
|
||||||
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
|
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
|
||||||
leverage: { zh: '杠杆倍数', en: 'Leverage' },
|
leverage: { zh: '杠杆倍数', en: 'Leverage' },
|
||||||
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)' },
|
leverageDesc: { zh: '交易使用的杠杆倍数 (1-20)', en: 'Leverage for trading (1-20)' },
|
||||||
|
|
||||||
// Grid parameters
|
// Grid parameters
|
||||||
gridCount: { zh: '网格数量', en: 'Grid Count' },
|
gridCount: { zh: '网格数量', en: 'Grid Count' },
|
||||||
@@ -171,7 +171,7 @@ export function GridConfigEditor({
|
|||||||
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
|
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
min={1}
|
min={1}
|
||||||
max={5}
|
max={20}
|
||||||
className="w-full px-3 py-2 rounded"
|
className="w-full px-3 py-2 rounded"
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -509,15 +509,13 @@ export interface GridStrategyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CoinSourceConfig {
|
export interface CoinSourceConfig {
|
||||||
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
|
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
|
||||||
static_coins?: string[];
|
static_coins?: string[];
|
||||||
excluded_coins?: string[]; // 排除的币种列表
|
excluded_coins?: string[]; // 排除的币种列表
|
||||||
use_ai500: boolean;
|
use_ai500: boolean;
|
||||||
ai500_limit?: number;
|
ai500_limit?: number;
|
||||||
use_oi_top: boolean;
|
use_oi_top: boolean;
|
||||||
oi_top_limit?: number;
|
oi_top_limit?: number;
|
||||||
use_oi_low: boolean;
|
|
||||||
oi_low_limit?: number;
|
|
||||||
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user