40 Commits

Author SHA1 Message Date
tinkle-community
a3b56a98bf fix(grid): add isRunning checks to prevent trades after Stop() is called 2026-01-18 20:06:41 +08:00
tinkle-community
e5f69bfea6 fix(grid): improve GridRiskPanel layout and fix liquidation data
- Make panel collapsible with summary badges when collapsed
- Use compact 2-column grid layout for detailed info
- Fix auth token key (token -> auth_token)
- Only calculate liquidation distance when position exists
2026-01-18 18:19:15 +08:00
tinkle-community
e198498f3a Merge branch 'dev' into ai-grid 2026-01-18 14:44:28 +08:00
tinkle-community
993db33466 fix: remove hardcoded test wallet address 2026-01-17 23:12:12 +08:00
tinkle-community
7f24a90851 feat(lighter): improve API key validation and market caching
- Add API key validation status tracking
- Add market list caching to reduce API calls
- Improve logging (debug vs info levels)
- Add comprehensive integration tests
- Update trader manager and store for lighter support
2026-01-17 23:07:35 +08:00
tinkle-community
19698529b8 feat(web): integrate GridRiskPanel into TraderDashboardPage 2026-01-17 23:01:30 +08:00
tinkle-community
35fcf17df4 feat(kernel): add box indicators to AI prompt
- Add BoxData field to GridContext
- Add box indicator table to both zh/en prompts
- Show breakout/warning alerts based on price position
2026-01-17 22:05:10 +08:00
tinkle-community
2b1012b85b feat: add grid risk panel with API endpoint
- Task 13: Add GridRiskInfo type to frontend
- Task 14: Add /traders/:id/grid-risk API endpoint
- Task 15: Add GetGridRiskInfo method to AutoTrader
- Task 16: Create GridRiskPanel component with i18n
2026-01-17 22:02:45 +08:00
tinkle-community
826276f58c feat(trader): integrate box breakout detection into grid cycle
- Task 10: Add checkBoxBreakout with 3-candle confirmation
- Task 11: Add checkFalseBreakoutRecovery for 50% position recovery
- Task 12: Add box/breakout/regime fields to GridState
2026-01-17 21:56:37 +08:00
tinkle-community
587efba52c feat(trader): add regime classification and breakout detection
Implements Tasks 6-9 for grid market regime awareness:
- Task 6: classifyRegimeLevel with Bollinger/ATR thresholds
- Task 7: detectBoxBreakout for multi-period box breakouts
- Task 8: confirmBreakout with 3-candle confirmation logic
- Task 9: getBreakoutAction mapping breakout levels to actions
2026-01-17 21:52:34 +08:00
tinkle-community
4642671e77 feat(store): add box and regime fields to grid models 2026-01-17 21:49:30 +08:00
tinkle-community
bd8cc9c176 feat(market): add GetBoxData for multi-period box calculation
Adds calculateBoxData internal function and GetBoxData public API that
fetches 1h klines and computes three Donchian box levels (short/mid/long).
This will be used by the grid trading system to detect market regime.
2026-01-17 21:47:07 +08:00
tinkle-community
7d7493b576 feat(market): add BoxData and RegimeLevel types 2026-01-17 21:44:25 +08:00
tinkle-community
cbe753b9e6 fix(market): handle invalid period in calculateDonchian 2026-01-17 21:43:14 +08:00
tinkle-community
5c79aa451e feat(market): add Donchian channel calculation
Add calculateDonchian function to compute highest high and lowest low
over a specified period. This is the foundation for box (range) detection
in the multi-period box indicator system for grid trading.
2026-01-17 21:39:21 +08:00
tinkle-community
0a2c62885b docs: add grid market regime implementation plan
20 tasks covering:
- Donchian channel calculation
- Box data types and API
- Regime classification (4 levels)
- Breakout detection and handling
- False breakout recovery
- Frontend risk panel
- AI prompt updates
2026-01-17 20:51:08 +08:00
tinkle-community
ac25dd334e docs: add grid market regime detection design
Design for enhanced market state recognition with:
- Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI)
- Multi-period box indicators (72/240/500 1h candles)
- 4-level ranging classification
- Breakout detection and handling
- Frontend risk control panel
2026-01-17 20:46:02 +08:00
tinkle-community
f4cdf2e532 fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic
The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity`
which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution
(gaussian, pyramid, uniform) where orders have different quantities, this could lead to
incorrect fill detection.

Now sums the actual PositionSize from filled levels for accurate comparison.
Also adds warning log when GetPositions() fails.
2026-01-14 13:02:58 +08:00
tinkle-community
f6411f05ba fix(grid): improve order state sync logic
- Don't assume missing orders are filled
- Compare position size to determine fill vs cancel
- Properly reset cancelled orders to empty state
- More accurate grid state tracking
2026-01-14 12:58:50 +08:00
tinkle-community
38be361eca fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels
Critical fix for grid auto-adjustment:
- Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered
  on current price before reinitializing grid levels
- Preserve filled positions during adjustment by saving and restoring
  them to the closest new level after reinitialization
- Hold mutex lock for the entire adjustment operation to ensure atomicity
- Add locked variants of calculateDefaultBounds, calculateATRBounds, and
  initializeGridLevels to use during adjustment

Without this fix, autoAdjustGrid was using old boundaries when creating
new grid levels, defeating the purpose of auto-adjustment when price
moved significantly.
2026-01-14 12:54:26 +08:00
tinkle-community
584bfae699 feat(grid): add automatic grid adjustment
- New checkGridSkew() detects imbalanced grid
- autoAdjustGrid() reinitializes around current price
- Prevents grid from becoming ineffective after drift
- Triggers when one side is 3x more filled than other
2026-01-14 12:49:22 +08:00
tinkle-community
73789f7fb7 fix(grid): update daily PnL when stop loss is executed
The updateDailyPnL() function was added but never called, leaving
DailyPnL always at 0 and preventing daily loss limit checks from
triggering.

This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss()
when a stop loss is executed. We update directly rather than calling
updateDailyPnL() because the mutex is already held in that function.
2026-01-14 12:45:31 +08:00
tinkle-community
65f333e73c feat(grid): enforce daily loss limit
- Add checkDailyLossLimit() function to check if daily loss exceeds limit
- Track daily PnL with auto-reset at midnight
- Pause grid when DailyLossLimitPct exceeded
- Add updateDailyPnL() helper for realized PnL tracking
- Prevent excessive single-day losses
2026-01-14 12:39:33 +08:00
tinkle-community
1454ad3112 feat(grid): enforce max drawdown limit with emergency exit
CRITICAL: Add drawdown protection
- New checkMaxDrawdown() function tracks peak equity
- emergencyExit() closes all positions and cancels orders
- Auto-pause grid when MaxDrawdownPct exceeded
- Protect capital from excessive losses
2026-01-14 12:34:45 +08:00
tinkle-community
ec81384b7a feat(grid): add breakout detection and auto-pause
CRITICAL: Detect price breakout from grid range
- New checkBreakout() function to detect upper/lower breakouts
- Auto-pause grid on significant breakout (>2%)
- Cancel all orders when breakout detected
- Prevent continued losses in trending market
- Minor breakouts (1-2%) logged for AI consideration
2026-01-14 12:30:43 +08:00
tinkle-community
c161632e2b feat(grid): implement stop loss execution
CRITICAL: Add code-level stop loss protection
- New checkAndExecuteStopLoss() function
- Checks each filled level against StopLossPct
- Automatically closes positions exceeding stop loss
- Called during every grid state sync
2026-01-14 12:25:59 +08:00
tinkle-community
8ef6045f9d fix(grid): add total position value limit check
CRITICAL: Prevent excessive position accumulation
- New checkTotalPositionLimit() function
- Checks current + pending + new order value
- Rejects orders that would exceed TotalInvestment x Leverage
- Logs clear error messages when limit exceeded
2026-01-14 12:21:43 +08:00
tinkle-community
d7d9dc5c42 fix(grid): prevent CancelOrder from canceling all orders
CRITICAL BUG FIX:
- CancelOrder no longer calls CancelAllOrders
- Try exchange-specific CancelOrder if available
- Return error if individual cancellation not supported
2026-01-14 12:18:24 +08:00
tinkle-community
90509ae783 fix(grid): add leverage setting before order placement
CRITICAL BUG FIX:
- Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder()
- Set leverage during grid initialization
- Log leverage setting results
2026-01-14 12:14:08 +08:00
tinkle-community
937527281e test: add Lighter API authentication tests and diagnostic tools 2026-01-13 14:03:28 +08:00
tinkle-community
2bc45827f3 fix: use auth query parameter instead of Authorization header for Lighter API 2026-01-13 13:51:58 +08:00
tinkle-community
68e8a6e4b0 fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch
Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck
2026-01-13 13:42:14 +08:00
tinkle-community
aa7aa94275 fix: address code review issues for GetOpenOrders
- Add error logging for OKX/Bitget API failures (was silently swallowed)
- Fix Lighter position side logic to handle reduce-only orders
- Change verbose debug logs from Infof to Debugf level
2026-01-13 13:38:09 +08:00
tinkle-community
13189fa3aa feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges
- Aster: uses /fapi/v3/openOrders endpoint
- OKX: uses /api/v5/trade/orders-pending and orders-algo-pending
- Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending
2026-01-13 13:34:43 +08:00
tinkle-community
33cf09e7fe fix: correct Lighter API response parsing for GetOpenOrders
- Changed response field from 'data' to 'orders' to match Lighter API
- Updated OrderResponse struct to match Lighter's actual field names
- Fixed field types: price/quantity as strings, is_ask for side
2026-01-13 13:28:18 +08:00
tinkle-community
ef91bec2dd debug: add logging for Lighter GetActiveOrders API call 2026-01-13 13:24:57 +08:00
tinkle-community
2fcbdbab36 fix: implement GetOpenOrders for Lighter exchange 2026-01-13 13:19:11 +08:00
tinkle-community
1786f0ff53 Merge branch 'dev' into ai-grid 2026-01-13 13:10:57 +08:00
tinkle-community
1b47249d57 Merge branch 'dev' into ai-grid 2026-01-13 13:07:01 +08:00
tinkle-community
5fb26c17dc feat: add AI grid trading and market regime classification
- Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook
- Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter)
- Add grid engine with ATR-based boundary calculation and fund distribution
- Add market regime classification documents (Chinese/English)
- Add GridConfigEditor component for frontend configuration
2026-01-13 10:33:02 +08:00
11 changed files with 64 additions and 573 deletions

View File

@@ -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 Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -447,7 +447,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_top":
@@ -467,27 +466,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
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
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 {
symbol = market.Normalize(symbol)
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) {
if limit <= 0 {
limit = 10
limit = 20
}
positions, err := e.nofxosClient.GetOITopPositions()
@@ -616,30 +583,6 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
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
// ============================================================================
@@ -1346,38 +1289,13 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
// 多信号源组合
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)"
return " (AI500+OI_Top dual signal)"
} else if len(sources) == 1 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top 持仓增加)"
case "oi_low":
return " (OI_Low 持仓减少)"
return " (OI_Top position growth)"
case "static":
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 {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
for i, decision := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}

View File

@@ -73,10 +73,8 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
return nil, fmt.Errorf("API returned failure status")
}
// 空列表是正常情况,不是错误
if len(response.Data.Coins) == 0 {
log.Printf(" AI500 returned empty coin list (no coins meet criteria currently)")
return []CoinData{}, nil
return nil, fmt.Errorf("coin list is empty")
}
// Set IsAvailable flag

View File

@@ -106,11 +106,11 @@ func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosit
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
positions, _, err := c.fetchOIRanking("top", "1h", 20)
data, err := c.GetOIRanking("1h", 20)
if err != nil {
return nil, err
}
return positions, nil
return data.TopPositions, nil
}
// GetOITopSymbols retrieves OI top coin symbol list
@@ -129,31 +129,6 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
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
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
if data == nil {

View File

@@ -158,19 +158,16 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
newEntryPrice = math.Round(newEntryPrice*100) / 100
newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"quantity": newQty,
"entry_quantity": newEntryQty,
"entry_price": newEntryPrice,
"fee": newFee,
"updated_at": nowMs,
}).Error
}
// 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 {
var pos TraderPosition
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
}
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{}{
"quantity": newQty,
"fee": newFee,
"exit_price": newExitPrice,
"realized_pnl": newPnL,
"updated_at": nowMs,
}).Error
}
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
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{}{
"exchange_id": exchangeID,
"exchange_type": exchangeType,
"updated_at": nowMs,
}).Error
}

View File

@@ -97,7 +97,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
// source type: "static" | "ai500" | "oi_top" | "mixed"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -107,14 +107,10 @@ 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
}
@@ -252,9 +248,7 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
UseAI500: true,
AI500Limit: 10,
UseOITop: false,
OITopLimit: 10,
UseOILow: false,
OILowLimit: 10,
OITopLimit: 20,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{

View File

@@ -534,12 +534,6 @@ func (at *AutoTrader) runCycle() error {
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)
at.saveEquitySnapshot(ctx)

View File

@@ -56,8 +56,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
}
}
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
// Record current time BEFORE querying, to avoid missing trades during sync
// 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
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
@@ -96,17 +100,18 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
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)
// IMPORTANT: Must run always, not just when symbolMap is empty,
// because a position might be fully closed (no active position) but have PnL
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
if err != nil {
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
} else {
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
if len(symbolMap) == 0 {
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
if err != nil {
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
} else {
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
}
}
}
@@ -117,9 +122,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync")
// DON'T update lastSyncTime to current time here!
// Keep using the last actual trade time from DB to avoid creating gaps
// The lastSyncTimeMs from DB already has +1000ms buffer added
// Update last sync time even if no changes
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock()
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)
// 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 {
// 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
}
@@ -171,12 +182,10 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0
skippedCount := 0
for _, trade := range allTrades {
// Check if trade already exists
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
skippedCount++
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"))
}
// Update lastSyncTime to the LATEST trade time (not current time!)
// 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)
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
return nil
}

View File

@@ -1,5 +1,5 @@
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'
interface CoinSourceEditorProps {
@@ -23,38 +23,27 @@ export function CoinSourceEditor({
sourceType: { zh: '数据来源类型', en: 'Source Type' },
static: { zh: '静态列表', en: 'Static List' },
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
oi_top: { zh: 'OI 持仓增', en: 'OI Increase' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
oi_top: { zh: 'OI Top 持仓增', en: 'OI Top' },
mixed: { zh: '混合模式', en: 'Mixed Mode' },
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
addCoin: { zh: '添加币种', en: 'Add Coin' },
useAI500: { zh: '启用 AI500 数据源', en: 'Enable AI500 Data Provider' },
ai500Limit: { zh: '数量上限', en: 'Limit' },
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
oiTopLimit: { zh: '数量上限', en: 'Limit' },
useOILow: { zh: '启用 OI 持仓减少榜', en: 'Enable OI Decrease' },
oiLowLimit: { zh: '数量上限', en: 'Limit' },
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
ai500Desc: {
zh: '使用 AI500 智能筛选的热门币种',
en: 'Use AI500 smart-filtered popular coins',
},
oiTopDesc: {
zh: '持仓增加榜,适合做多',
en: 'OI increase ranking, for long',
},
oi_lowDesc: {
zh: '持仓减少榜,适合做空',
en: 'OI decrease ranking, for short',
zh: '使用持仓量增长最快的币种',
en: 'Use coins with fastest OI growth',
},
mixedDesc: {
zh: '组合多种数据源',
en: 'Combine multiple sources',
zh: '组合多种数据源AI500 + OI Top + 自定义',
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' },
excludedCoins: { zh: '排除币种', en: 'Excluded Coins' },
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: 'ai500', icon: Database, color: '#F0B90B' },
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
{ value: 'mixed', icon: Shuffle, color: '#60a5fa' },
{ value: 'mixed', icon: Database, color: '#60a5fa' },
] 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
const xyzDexAssets = new Set([
// Stocks
@@ -193,7 +156,7 @@ export function CoinSourceEditor({
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('sourceType')}
</label>
<div className="grid grid-cols-5 gap-2">
<div className="grid grid-cols-4 gap-3">
{sourceTypes.map(({ value, icon: Icon, color }) => (
<button
key={value}
@@ -219,8 +182,8 @@ export function CoinSourceEditor({
</div>
</div>
{/* Static Coins - only for static mode */}
{config.source_type === 'static' && (
{/* Static Coins */}
{(config.source_type === 'static' || config.source_type === 'mixed') && (
<div>
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('staticCoins')}
@@ -320,8 +283,8 @@ export function CoinSourceEditor({
)}
</div>
{/* AI500 Options - only for ai500 mode */}
{config.source_type === 'ai500' && (
{/* AI500 Options */}
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
<div
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
>
@@ -377,8 +340,8 @@ export function CoinSourceEditor({
</div>
)}
{/* OI Top Options - only for oi_top mode */}
{config.source_type === 'oi_top' && (
{/* OI Top Options */}
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
<div
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">
<TrendingUp className="w-4 h-4 text-nofx-success" />
<span className="text-sm font-medium text-nofx-text">
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
OI Top {t('dataSourceConfig')}
</span>
<NofxOSBadge />
</div>
@@ -412,10 +375,10 @@ export function CoinSourceEditor({
{t('oiTopLimit')}:
</span>
<select
value={config.oi_top_limit || 10}
value={config.oi_top_limit || 20}
onChange={(e) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 10 })
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
}
disabled={disabled}
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>
)}
{/* 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>
)
}

View File

@@ -47,7 +47,7 @@ export function GridConfigEditor({
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
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
gridCount: { zh: '网格数量', en: 'Grid Count' },
@@ -171,7 +171,7 @@ export function GridConfigEditor({
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
disabled={disabled}
min={1}
max={5}
max={20}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>

View File

@@ -509,15 +509,13 @@ export interface GridStrategyConfig {
}
export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
static_coins?: string[];
excluded_coins?: string[]; // 排除的币种列表
use_ai500: boolean;
ai500_limit?: number;
use_oi_top: boolean;
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
}