39 Commits

Author SHA1 Message Date
google-labs-jules[bot]
f63eab72f7 🎨 Palette: Fix backend CI failures
- Added `//go:build ignore` to standalone scripts in `scripts/` to prevent redeclaration of `main`.
- Fixed redundant newline in `fmt.Println` in `cmd/lighter_test/main.go`.
- Updated `trader/hyperliquid/trader_test.go` to match the actual error message phrasing.

Co-authored-by: tinkle-community <240652709+tinkle-community@users.noreply.github.com>
2026-02-05 16:11:02 +00:00
google-labs-jules[bot]
6c26f9eb95 🎨 Palette: Improve LoginPage accessibility
- Added aria-label and aria-pressed to the password visibility toggle in LoginPage.
- Added showPassword and hidePassword translations to both English and Chinese.
- Ensured changes are micro and focused on accessibility.

Co-authored-by: tinkle-community <240652709+tinkle-community@users.noreply.github.com>
2026-02-05 15:54:28 +00:00
tinkle-community
22f6ddc045 feat(web): add UI for grid direction adjustment settings
- Add enable_direction_adjust and direction_bias_ratio to GridStrategyConfig
- Add Direction Auto-Adjust section in GridConfigEditor
- Include toggle switch, bias ratio slider, and explanation text
- Support both Chinese and English translations
2026-02-04 11:30:54 +08:00
tinkle-community
773857351f feat(grid): auto-adjust grid direction based on box breakouts
Add GridDirection type with 5 states:
- neutral (50% buy + 50% sell)
- long/short (100% one direction)
- long_bias/short_bias (70%/30% configurable)

Direction adjustment logic:
- Short box breakout → bias direction (long_bias/short_bias)
- Mid box breakout → full direction (long/short)
- Long box breakout → emergency handling (unchanged)
- Recovery: long → long_bias → neutral ← short_bias ← short

Config options:
- EnableDirectionAdjust (default: false)
- DirectionBiasRatio (default: 0.7)

Includes unit tests for all direction-related functions.
2026-02-04 11:25:47 +08:00
tinkle-community
382e756328 fix: use Anthropic accent color (#CC785C) for Claude icon visibility on dark bg 2026-02-04 02:49:07 +08:00
tinkle-community
87ef618b04 fix: update Claude/Anthropic icon to official black logo (no fill color) 2026-02-04 02:47:08 +08:00
tinkle-community
ca92b849cd fix: KuCoin timestamp sync, improve no-coins handling, update README icons
- Add server time synchronization for KuCoin API to fix timestamp error (400002)
- Return empty list instead of error when no available coins (ai500.go)
- Save cycle record even when no candidate coins (show in frontend without red error)
- Update Claude icon to Anthropic dark brand color (#141413)
- Add exchange and AI model icons to README.md and README.ja.md
2026-02-04 02:41:37 +08:00
tinkle-community
23dbbf6bdd feat(kucoin): integrate KuCoin exchange support
- Add kucoin to validTypes in api/server.go
- Add KuCoin trader creation in trader_manager.go
- Fix PostgreSQL duplicate key in equity.go (Omit ID)
- Start KuCoin order sync in auto_trader.go
- Update FooterSection UI
2026-02-04 02:12:37 +08:00
tinkle-community
b32a3566e6 feat(kucoin): add order sync and fix price precision
- Add KuCoin order sync with proper API response parsing
- Use openFeePay/closeFeePay to determine open/close trades
- Get contract multiplier from API for accurate qty calculation
- Fix price rounding: 2 decimals -> 8 decimals for low-price coins
- Add comprehensive tests for trades, positions, and P&L
2026-02-04 02:10:26 +08:00
tinkle-community
a5c4d35074 refactor: clean up gate trader configuration 2026-02-03 12:40:53 +08:00
tinkle-community
7b908a3e39 feat: add Gate.io to supporters section 2026-01-31 23:32:13 +08:00
tinkle-community
093d2a329d feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration:
- Add Gate trader with full Trader interface implementation
- Add order_sync.go for background trade synchronization
- Fix quantity display (convert contracts to actual tokens via quanto_multiplier)
- Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort
- Add Gate-specific CoinAnk K-line data source support
- Add Gate to supported exchanges in frontend and backend
- Add Gate/KuCoin logo SVG icons

Trader Package Refactoring:
- Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/)
- Create types/ package for shared types to avoid circular dependencies
- Move TraderTestSuite to trader/testutil package to avoid import cycles
- Update market.GetWithExchange to support exchange-specific data
2026-01-31 23:15:17 +08:00
tinkle-community
40474d258c feat: improve UI/UX for exchange and model configuration
- Redesign ExchangeConfigModal with icon card selection grid
- Add step indicator for multi-step exchange configuration flow
- Redesign ModelConfigModal with icon card selection pattern
- Add KuCoin Futures exchange support with icon
- Fix IPv4 detection for IP whitelist display
2026-01-31 20:23:13 +08:00
tinkle-community
e19e289c58 docs: add KuCoin exchange support 2026-01-31 18:26:38 +08:00
tinkle-community
cca24e05c1 fix: bitget plan orders API requires planType parameter
- Add planType=profit_loss parameter for SL/TP orders
- Parse stopLossTriggerPrice and stopSurplusTriggerPrice fields
- Fix planType values: pos_loss, pos_profit (not loss_plan, profit_plan)
2026-01-31 18:08:31 +08:00
tinkle-community
581ff57323 fix: bitget order sync parsing for V2 API
- Support both wrapped (fillList) and direct array response formats
- Fix tradeSide parsing for one-way mode (buy_single/sell_single)
- Fix fee extraction from nested feeDetail array structure
2026-01-31 17:32:50 +08:00
tinkle-community
b137122b18 fix(okx): correctly parse slTriggerPx/tpTriggerPx for algo orders 2026-01-31 14:30:01 +08:00
tinkle-community
5ea9a3990e docs: center README titles 2026-01-31 02:17:06 +08:00
tinkle-community
5b5199359c docs: rebrand as AI Trading OS across all languages 2026-01-31 02:15:07 +08:00
wqqqqqq
9dbc861cdf feat: add depth websocket from coinank (#1362) 2026-01-27 22:07:38 +08:00
tinkle-community
fcc0267a46 docs: update sponsors list (11 sponsors) 2026-01-23 21:23:38 +08:00
tinkle-community
c9150e8273 feat: add OI Low coin source and improve Mixed mode UI
- Add oi_low as independent source_type for short opportunities
- Redesign Mixed mode with card-based selector (2x2 grid)
- Show combination summary with total coin limit
- Support both Chinese and English languages
- Change default limits to 10 for OI Top and OI Low
2026-01-23 20:50:23 +08:00
tinkle-community
fcaabea6cb feat: add oi_low coin source for short opportunities
- Add GetOILowPositions/GetOILowSymbols in oi.go
- Add UseOILow/OILowLimit config fields
- Add oi_low case in GetCandidateCoins
- Support oi_low in mixed mode
- Update source tag formatting
2026-01-23 20:16:30 +08:00
tinkle-community
b5716ff3cb fix: handle empty AI500 coin list gracefully instead of error 2026-01-23 20:12:11 +08:00
tinkle-community
2f54d1d4c0 docs: update sponsors list (8 sponsors) 2026-01-19 23:23:14 +08:00
tinkle-community
0b448558ca docs: update sponsors list (5 sponsors) 2026-01-19 20:27:41 +08:00
tinkle-community
84276f64ae docs: add sponsor @1733055465 2026-01-19 19:11:55 +08:00
tinkle-community
5560df133e docs: use manual sponsor list instead of workflow 2026-01-19 19:06:54 +08:00
tinkle-community
f43c63699b docs: trigger sponsors update on new sponsorship events 2026-01-19 19:05:12 +08:00
tinkle-community
7b1edaa51f docs: add auto-update sponsors workflow 2026-01-19 19:04:33 +08:00
tinkle-community
ed8ad63288 docs: add sponsors section to README 2026-01-19 18:48:36 +08:00
tinkle-community
a7370efc2f fix(sync): use actual trade time instead of current time for lastSyncTime
- Remove syncStartTimeMs that was causing sync gaps
- Update binanceSyncState to latest trade's timestamp after successful sync
- Don't update lastSyncTime when no trades found (keep using DB value)

Fixes issue where trades between last sync and current time were missed
2026-01-19 17:33:13 +08:00
tinkle-community
5b384d126f fix(sync): add diagnostic logging for debugging sync issues
- Log lastSyncTimeMs and nowMs raw values for timestamp debugging
- Count and log skipped trades (already exist in DB)
- Helps diagnose positions sync stops at 6am issue
2026-01-19 16:25:02 +08:00
tinkle-community
1532b55d77 fix(sync): always query REALIZED_PNL to detect closed positions
Previously Method 4 (REALIZED_PNL) only ran when symbolMap was empty.
This caused fully-closed positions to be missed if other symbols were detected.

Now REALIZED_PNL is always queried to catch positions that:
- Have no active position (fully closed)
- Were missed by COMMISSION detection (VIP users, BNB fee discount)
2026-01-19 15:50:53 +08:00
tinkle-community
0e75b80d95 Revert "fix(sync): handle close trades without matching open position"
This reverts commit 9c57134dfb.
2026-01-19 15:35:17 +08:00
tinkle-community
9c57134dfb fix(sync): handle close trades without matching open position
- Create synthetic CLOSED position when close trade has no matching open position
- This happens when position was opened before sync window (>24h) but closed during sync
- Multiple close trades are merged into same synthetic position
- Added GetSyntheticClosedPosition and UpdateSyntheticPosition functions
- Synthetic positions marked with close_reason='sync_partial' for identification
2026-01-19 15:33:29 +08:00
tinkle-community
7ce7361cef fix(sync): add updated_at to position updates and auto-close when quantity=0
- UpdatePositionQuantityAndPrice: add updated_at timestamp
- ReducePositionQuantity: add updated_at and auto-close position when qty <= 0.0001
- UpdatePositionExchangeInfo: add updated_at timestamp

Fixes position sync issue after int64 timestamp migration where GORM autoUpdateTime
tag no longer works with int64 fields
2026-01-19 15:13:34 +08:00
tinkle-community
7a1643c56c fix: leverage validation bug and limit grid leverage to 1-5
- Fix Go range loop copy issue in validateDecisions (leverage auto-adjust was modifying copy, not original)
- Limit grid leverage from 1-20 to 1-5 for safer grid trading
2026-01-19 13:16:16 +08:00
tinkle-community
7e96c5d0f2 Ai grid (#1344)
* 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

* fix: implement GetOpenOrders for Lighter exchange

* debug: add logging for Lighter GetActiveOrders API call

* 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

* 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

* 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

* 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

* fix: use auth query parameter instead of Authorization header for Lighter API

* test: add Lighter API authentication tests and diagnostic tools

* 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

* 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

* 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

* 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

* 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

* 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

* 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

* 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.

* 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

* 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.

* 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

* 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.

* 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

* 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

* 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.

* fix(market): handle invalid period in calculateDonchian

* feat(market): add BoxData and RegimeLevel types

* 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.

* feat(store): add box and regime fields to grid models

* 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

* 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

* 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

* 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

* feat(web): integrate GridRiskPanel into TraderDashboardPage

* 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

* fix: remove hardcoded test wallet address

* 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

* fix(grid): add isRunning checks to prevent trades after Stop() is called
2026-01-19 12:07:14 +08:00
89 changed files with 7248 additions and 1892 deletions

3
.Jules/palette.md Normal file
View File

@@ -0,0 +1,3 @@
## 2025-05-22 - [Accessibility: Password Toggles]
**Learning:** Icon-only buttons for password visibility toggles are a common accessibility gap. Adding `aria-label` and `aria-pressed` ensures screen reader users understand the button's purpose and state.
**Action:** Always check password fields and add these attributes along with corresponding translations in `translations.ts`.

View File

@@ -103,6 +103,43 @@ Binance互換の分散型無期限先物取引所
---
## 対応取引所
### CEX中央集権型取引所
| 取引所 | ステータス | 登録(手数料割引) |
|:-------|:----------:|:-------------------|
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX分散型無期限取引所
| 取引所 | ステータス | 登録(手数料割引) |
|:-------|:----------:|:-------------------|
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
---
## 対応AIモデル
| AIモデル | ステータス | APIキー取得 |
|:---------|:----------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [APIキー取得](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [APIキー取得](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [APIキー取得](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [APIキー取得](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [APIキー取得](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [APIキー取得](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [APIキー取得](https://platform.moonshot.cn) |
---
## 📸 スクリーンショット
### 🏆 競争モード - リアルタイムAIバトル

View File

@@ -1,9 +1,21 @@
# NOFX - Agentic Trading OS
<h1 align="center">NOFX — Open Source AI Trading OS</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>The infrastructure layer for AI-powered financial trading.</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
| CONTRIBUTOR AIRDROP PROGRAM |
|:----------------------------------:|
@@ -14,10 +26,6 @@
---
## AI-Powered Multi-Asset Trading Platform
**NOFX** is an open-source AI trading system that lets you run multiple AI models to trade automatically. Configure strategies through a web interface, monitor performance in real-time, and let AI agents compete to find the best trading approach.
### Supported Markets
| Market | Trading | Status |
@@ -30,7 +38,7 @@
### Core Features
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
@@ -70,33 +78,35 @@ To use NOFX, you'll need:
### CEX (Centralized Exchanges)
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
---
## Supported AI Models
| AI Model | Status | Get API Key |
|----------|--------|-------------|
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|:---------|:------:|:------------|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
---
@@ -488,6 +498,26 @@ 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

@@ -20,6 +20,15 @@ import (
"nofx/provider/twelvedata"
"nofx/store"
"nofx/trader"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
hyperliquidtrader "nofx/trader/hyperliquid"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"strconv"
"strings"
"time"
@@ -256,13 +265,14 @@ func (s *Server) handleGetServerIP(c *gin.Context) {
})
}
// getPublicIPFromAPI Get public IP via third-party API
// getPublicIPFromAPI Get public IP via third-party API (IPv4 only)
func getPublicIPFromAPI() string {
// Try multiple public IP query services
// Try multiple public IP query services (IPv4-only endpoints)
services := []string{
"https://api.ipify.org?format=text",
"https://icanhazip.com",
"https://ifconfig.me",
"https://api4.ipify.org?format=text", // IPv4 only
"https://ipv4.icanhazip.com", // IPv4 only
"https://v4.ident.me", // IPv4 only
"https://api.ipify.org?format=text", // May return IPv4 or IPv6
}
client := &http.Client{
@@ -284,8 +294,9 @@ func getPublicIPFromAPI() string {
}
ip := strings.TrimSpace(string(body[:n]))
// Verify if it's a valid IP address
if net.ParseIP(ip) != nil {
parsedIP := net.ParseIP(ip)
// Verify if it's a valid IPv4 address (not containing ":")
if parsedIP != nil && parsedIP.To4() != nil {
return ip
}
}
@@ -583,32 +594,43 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader(
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
)
case "aster":
tempTrader, createErr = trader.NewAsterTrader(
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = trader.NewBybitTrader(
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = trader.NewOKXTrader(
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = trader.NewBitgetTrader(
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
@@ -616,7 +638,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2(
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
@@ -1141,32 +1163,43 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader(
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
)
case "aster":
tempTrader, createErr = trader.NewAsterTrader(
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = trader.NewBybitTrader(
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = trader.NewOKXTrader(
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = trader.NewBitgetTrader(
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
@@ -1174,7 +1207,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2(
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
@@ -1293,32 +1326,43 @@ func (s *Server) handleClosePosition(c *gin.Context) {
// Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType {
case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader(
tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
)
case "aster":
tempTrader, createErr = trader.NewAsterTrader(
tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey),
)
case "bybit":
tempTrader = trader.NewBybitTrader(
tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "okx":
tempTrader = trader.NewOKXTrader(
tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "bitget":
tempTrader = trader.NewBitgetTrader(
tempTrader = bitget.NewBitgetTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
)
case "gate":
tempTrader = gate.NewGateTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
)
case "kucoin":
tempTrader = kucoin.NewKuCoinTrader(
string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase),
@@ -1326,7 +1370,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2(
tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex,
@@ -1405,7 +1449,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "gate":
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return
}
@@ -1959,7 +2003,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
// Validate exchange type
validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true,
"hyperliquid": true, "aster": true, "lighter": true,
"hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true,
}
if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
@@ -2491,11 +2535,16 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
coinankExchange = coinank_enum.Okex
case "bitget":
coinankExchange = coinank_enum.Bitget
case "gate":
coinankExchange = coinank_enum.Gate
case "aster":
coinankExchange = coinank_enum.Aster
case "lighter":
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
case "kucoin":
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
default:
// For any unknown exchange, default to Binance
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
@@ -3340,6 +3389,8 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
{ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
{ExchangeType: "gate", Name: "Gate.io Futures", Type: "cex"},
{ExchangeType: "kucoin", Name: "KuCoin Futures", Type: "cex"},
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},

View File

@@ -75,7 +75,7 @@ func main() {
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
os.Exit(1)
}
fmt.Println("SUCCESS: TxClient created\n")
fmt.Println("SUCCESS: TxClient created")
// Step 3: Generate auth token
fmt.Println("Step 3: Generating auth token...")

View File

@@ -1,22 +1,30 @@
# NOFX - AI トレーディングシステム
<h1 align="center">NOFX — オープンソース AI トレーディング OS</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
---
## AI 駆動の暗号通貨取引プラットフォーム
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
### コア機能
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
@@ -55,6 +63,8 @@ NOFXを使用するには以下が必要です:
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (分散型永久先物取引所)

View File

@@ -1,22 +1,30 @@
# NOFX - AI 트레이딩 시스템
<h1 align="center">NOFX — 오픈소스 AI 트레이딩 OS</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>AI 기반 금융 거래를 위한 인프라 레이어</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
---
## AI 기반 암호화폐 거래 플랫폼
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
### 핵심 기능
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
@@ -55,6 +63,8 @@ NOFX를 사용하려면 다음이 필요합니다:
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ 지원 | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ 지원 | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (탈중앙화 영구 선물 거래소)

View File

@@ -1,22 +1,30 @@
# NOFX - AI Торговая Система
<h1 align="center">NOFX — Open Source AI Торговая ОС</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>Инфраструктурный слой для AI-powered финансовой торговли</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
---
## Криптовалютная торговая платформа на базе ИИ
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
### Основные функции
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
@@ -55,6 +63,8 @@
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ Поддерживается | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ Поддерживается | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (Децентрализованные биржи)

View File

@@ -1,22 +1,30 @@
# NOFX - AI Торгова Система
<h1 align="center">NOFX — Open Source AI Торгова ОС</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>Інфраструктурний рівень для AI-powered фінансової торгівлі</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
---
## Криптовалютна торгова платформа на базі ШІ
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
### Основні функції
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
@@ -55,6 +63,8 @@
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ Підтримується | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ Підтримується | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (Децентралізовані біржі)

View File

@@ -1,22 +1,30 @@
# NOFX - Hệ Thống Giao Dịch AI
<h1 align="center">NOFX Hệ Điều Hành Giao Dịch AI Mã Nguồn Mở</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>Lớp cơ sở hạ tầng cho giao dịch tài chính AI-powered</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
---
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
**NOFX** là hệ thống giao dịch AI mã nguồn mở cho phép bạn chạy nhiều mô hình AI để tự động giao dịch hợp đồng tương lai crypto. Cấu hình chiến lược qua giao diện web, theo dõi hiệu suất theo thời gian thực, và để các AI agent cạnh tranh tìm ra phương pháp giao dịch tốt nhất.
### Tính Năng Chính
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter từ một nền tảng
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
@@ -55,6 +63,8 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ Hỗ trợ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ Hỗ trợ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (Sàn Phi Tập Trung)

View File

@@ -1,9 +1,21 @@
# NOFX - AI 交易系统
<h1 align="center">NOFX — 开源 AI 交易操作系统</h1>
[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go)](https://golang.org/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>AI 驱动金融交易的基础设施层</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
@@ -16,14 +28,10 @@
---
## AI 驱动的加密货币交易平台
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
### 核心功能
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
@@ -67,6 +75,8 @@
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ 已支持 | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ 已支持 | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (去中心化永续交易所)

2
go.mod
View File

@@ -23,6 +23,7 @@ require (
require (
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/antihax/optional v1.0.0 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
@@ -44,6 +45,7 @@ require (
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect

4
go.sum
View File

@@ -8,6 +8,8 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
@@ -68,6 +70,8 @@ github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeD
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=

View File

@@ -447,6 +447,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_top":
@@ -466,6 +467,27 @@ 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":
@@ -491,6 +513,17 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
}
if coinSource.UseOILow {
oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit)
if err != nil {
logger.Infof("⚠️ Failed to get OI Low: %v", err)
} else {
for _, coin := range oiLowCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low")
}
}
}
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
if _, exists := symbolSources[symbol]; !exists {
@@ -561,7 +594,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
limit = 10
}
positions, err := e.nofxosClient.GetOITopPositions()
@@ -583,6 +616,30 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil
}
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 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
// ============================================================================
@@ -1289,13 +1346,38 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
return " (AI500+OI_Top dual signal)"
// 多信号源组合
hasAI500 := false
hasOITop := false
hasOILow := false
for _, s := range sources {
switch s {
case "ai500":
hasAI500 = true
case "oi_top":
hasOITop = true
case "oi_low":
hasOILow = true
}
}
if hasAI500 && hasOITop {
return " (AI500+OI_Top dual signal)"
}
if hasAI500 && hasOILow {
return " (AI500+OI_Low dual signal)"
}
if hasOITop && hasOILow {
return " (OI_Top+OI_Low)"
}
return " (Multiple sources)"
} else if len(sources) == 1 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top position growth)"
return " (OI_Top 持仓增加)"
case "oi_low":
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
}
@@ -1767,8 +1849,8 @@ func compactArrayOpen(s string) string {
// ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i, decision := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}

View File

@@ -84,6 +84,9 @@ type GridContext struct {
// Box indicators (Donchian Channels)
BoxData *market.BoxData `json:"box_data,omitempty"`
// Grid direction (neutral, long, short, long_bias, short_bias)
CurrentDirection string `json:"current_direction,omitempty"`
}
// ============================================================================
@@ -279,6 +282,20 @@ func buildGridUserPromptZh(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
if ctx.CurrentDirection != "" {
directionDescZh := map[string]string{
"neutral": "中性 (50%买+50%卖)",
"long": "做多 (100%买)",
"short": "做空 (100%卖)",
"long_bias": "偏多 (70%买+30%卖)",
"short_bias": "偏空 (30%买+70%卖)",
}
desc := directionDescZh[ctx.CurrentDirection]
if desc == "" {
desc = ctx.CurrentDirection
}
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
}
sb.WriteString("\n")
// Grid levels detail
@@ -376,6 +393,20 @@ func buildGridUserPromptEn(ctx *GridContext) string {
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
if ctx.CurrentDirection != "" {
directionDescEn := map[string]string{
"neutral": "Neutral (50% buy + 50% sell)",
"long": "Long (100% buy)",
"short": "Short (100% sell)",
"long_bias": "Long Bias (70% buy + 30% sell)",
"short_bias": "Short Bias (30% buy + 70% sell)",
}
desc := directionDescEn[ctx.CurrentDirection]
if desc == "" {
desc = ctx.CurrentDirection
}
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
}
sb.WriteString("\n")
// Grid levels detail

View File

@@ -690,6 +690,13 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
case "gate":
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
case "kucoin":
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr

View File

@@ -31,7 +31,7 @@ var (
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
@@ -67,13 +67,44 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
return nil, fmt.Errorf("unsupported interval: %s", interval)
}
// Map exchange string to coinank enum
var coinankExchange coinank_enum.Exchange
switch strings.ToLower(exchange) {
case "binance":
coinankExchange = coinank_enum.Binance
case "bybit":
coinankExchange = coinank_enum.Bybit
case "okx":
coinankExchange = coinank_enum.Okex
case "bitget":
coinankExchange = coinank_enum.Bitget
case "gate":
coinankExchange = coinank_enum.Gate
case "hyperliquid":
coinankExchange = coinank_enum.Hyperliquid
case "aster":
coinankExchange = coinank_enum.Aster
default:
// Default to Binance for unknown exchanges
coinankExchange = coinank_enum.Binance
}
// Call CoinAnk free/open API (no authentication required)
ctx := context.Background()
ts := time.Now().UnixMilli()
// Use "To" side to search backward from current time (get historical klines)
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
return nil, fmt.Errorf("CoinAnk API error: %w", err)
// If exchange-specific data fails, fallback to Binance
if coinankExchange != coinank_enum.Binance {
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
}
} else {
return nil, fmt.Errorf("CoinAnk API error: %w", err)
}
}
// Convert coinank kline format to market.Kline format
@@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
return klines, nil
}
// Get retrieves market data for the specified token
// Get retrieves market data for the specified token (uses Binance data by default)
func Get(symbol string) (*Data, error) {
return GetWithExchange(symbol, "binance")
}
// GetWithExchange retrieves market data for the specified token using exchange-specific data
func GetWithExchange(symbol, exchange string) (*Data, error) {
var klines3m, klines4h []Kline
var err error
// Normalize symbol
@@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) {
// Check if this is an xyz dex asset (use Hyperliquid API)
isXyzAsset := IsXyzDexAsset(symbol)
// For hyperliquid exchange, also use Hyperliquid API
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
if isXyzAsset {
if useHyperliquidAPI {
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
}
} else {
// Use CoinAnk for regular crypto assets
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
// Use CoinAnk for regular crypto assets with exchange-specific data
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
}
}
@@ -166,15 +205,15 @@ func Get(symbol string) (*Data, error) {
}
// Get 4-hour K-line data
if isXyzAsset {
if useHyperliquidAPI {
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
}
} else {
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
}
}
@@ -290,8 +329,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
continue
}
} else {
// Use CoinAnk for regular crypto assets
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
// Use CoinAnk for regular crypto assets (default to Binance)
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
if err != nil {
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
continue
@@ -1068,6 +1107,11 @@ func Normalize(symbol string) string {
return "xyz:" + base
}
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
symbol = strings.ReplaceAll(symbol, "_", "")
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
symbol = strings.ReplaceAll(symbol, "-", "")
// For regular crypto assets
if strings.HasSuffix(symbol, "USDT") {
return symbol
@@ -1283,7 +1327,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
}
if err != nil {

View File

@@ -226,3 +226,37 @@ const (
BreakoutMid BreakoutLevel = "mid"
BreakoutLong BreakoutLevel = "long"
)
// GridDirection represents the current grid trading direction bias
type GridDirection string
const (
GridDirectionNeutral GridDirection = "neutral" // 50% buy + 50% sell
GridDirectionLong GridDirection = "long" // 100% buy
GridDirectionShort GridDirection = "short" // 100% sell
GridDirectionLongBias GridDirection = "long_bias" // 70% buy + 30% sell (default)
GridDirectionShortBias GridDirection = "short_bias" // 30% buy + 70% sell (default)
)
// GetBuySellRatio returns the buy and sell ratio for this direction
// biasRatio is the ratio for biased directions (default 0.7 means 70%/30%)
func (d GridDirection) GetBuySellRatio(biasRatio float64) (buyRatio, sellRatio float64) {
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7 // Default 70%/30%
}
switch d {
case GridDirectionNeutral:
return 0.5, 0.5
case GridDirectionLong:
return 1.0, 0.0
case GridDirectionShort:
return 0.0, 1.0
case GridDirectionLongBias:
return biasRatio, 1.0 - biasRatio
case GridDirectionShortBias:
return 1.0 - biasRatio, biasRatio
default:
return 0.5, 0.5
}
}

View File

@@ -0,0 +1,108 @@
package coinank_api
import (
"context"
"encoding/json"
"nofx/provider/coinank/coinank_enum"
"golang.org/x/net/websocket"
)
const MainDepthWsUrl = "wss://ws.coinank.com/wsDepth/wsKline"
type DepthWs struct {
conn *websocket.Conn
DepthV3Ch <-chan *WsResult[DepthV3]
}
// DepthWsConn connect ws , read data from DepthV3Ch
func DepthWsConn(ctx context.Context) (*DepthWs, error) {
conn, ch, err := depth_ws(ctx)
if err != nil {
return nil, err
}
ws := &DepthWs{
conn: conn,
DepthV3Ch: ch,
}
return ws, nil
}
// Subscribe subscribe depth
func (ws *DepthWs) Subscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
info := SubscribeInfo{
Op: "subscribe",
Args: args,
}
json, err := json.Marshal(info)
if err != nil {
return err
}
err = websocket.Message.Send(ws.conn, json)
if err != nil {
return err
}
return nil
}
// UnSubscribe unsubscribe depth
func (ws *DepthWs) UnSubscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
info := SubscribeInfo{
Op: "unsubscribe",
Args: args,
}
json, err := json.Marshal(info)
if err != nil {
return err
}
err = websocket.Message.Send(ws.conn, json)
if err != nil {
return err
}
return nil
}
// Close websocket
func (ws *DepthWs) Close() error {
return ws.conn.Close()
}
func depth_ws(ctx context.Context) (*websocket.Conn, <-chan *WsResult[DepthV3], error) {
config, err := websocket.NewConfig(MainDepthWsUrl, "http://localhost")
if err != nil {
return nil, nil, err
}
conn, err := config.DialContext(ctx)
if err != nil {
return nil, nil, err
}
ch := make(chan *WsResult[DepthV3], 1024)
go depth_read(conn, ch)
return conn, ch, nil
}
func depth_read(conn *websocket.Conn, ch chan *WsResult[DepthV3]) {
defer conn.Close()
defer close(ch)
var msg string
for {
err := websocket.Message.Receive(conn, &msg)
if err != nil {
return
}
var depth WsResult[DepthV3]
err = json.Unmarshal([]byte(msg), &depth)
if err == nil {
ch <- &depth
}
}
}
type DepthV3 struct {
Type string `json:"type"`
Ts uint64 `json:"ts"`
Asks [][]string `json:"asks"`
Bids [][]string `json:"bids"`
}

View File

@@ -0,0 +1,42 @@
package coinank_api
import (
"context"
"encoding/json"
"fmt"
"nofx/provider/coinank/coinank_enum"
"testing"
"time"
)
func TestDepthWs(t *testing.T) {
ctx := context.TODO()
ws, err := DepthWsConn(ctx)
if err != nil {
t.Fatal(err)
}
go func() {
for tickers := range ws.DepthV3Ch {
msg, err := json.Marshal(tickers)
if err != nil {
fmt.Println("json err:", err)
}
fmt.Println(string(msg))
}
fmt.Println("DepthV3Ch closed")
}()
err = ws.Subscribe("BTCUSDT", coinank_enum.Binance, "0.1")
if err != nil {
t.Fatal(err)
}
fmt.Println("sub success")
time.Sleep(10 * time.Second)
err = ws.UnSubscribe("BTCUSDT", coinank_enum.Binance, "0.1")
if err != nil {
t.Fatal(err)
}
fmt.Println("unsub success")
time.Sleep(10 * time.Second)
ws.Close()
fmt.Println("cancel success")
}

View File

@@ -73,8 +73,10 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
return nil, fmt.Errorf("API returned failure status")
}
// 空列表是正常情况,不是错误
if len(response.Data.Coins) == 0 {
return nil, fmt.Errorf("coin list is empty")
log.Printf(" AI500 returned empty coin list (no coins meet criteria currently)")
return []CoinData{}, nil
}
// Set IsAvailable flag
@@ -103,7 +105,8 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
}
if len(availableCoins) == 0 {
return nil, fmt.Errorf("no available coins")
// Empty list is normal - just return empty slice, not an error
return []string{}, nil
}
// Sort by Score descending (bubble sort)
@@ -145,10 +148,7 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
}
}
if len(symbols) == 0 {
return nil, fmt.Errorf("no available coins")
}
// Empty list is normal - just return empty slice, not an error
return symbols, nil
}

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) {
data, err := c.GetOIRanking("1h", 20)
positions, _, err := c.fetchOIRanking("top", "1h", 20)
if err != nil {
return nil, err
}
return data.TopPositions, nil
return positions, nil
}
// GetOITopSymbols retrieves OI top coin symbol list
@@ -129,6 +129,31 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
return symbols, nil
}
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
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

@@ -1,3 +1,5 @@
//go:build ignore
package main
import (

View File

@@ -1,3 +1,5 @@
//go:build ignore
package main
import (

View File

@@ -1,3 +1,5 @@
//go:build ignore
package main
import (

View File

@@ -1,3 +1,5 @@
//go:build ignore
package main
import (

View File

@@ -1,3 +1,5 @@
//go:build ignore
package main
import (

View File

@@ -53,7 +53,9 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error {
snapshot.Timestamp = snapshot.Timestamp.UTC()
}
if err := s.db.Create(snapshot).Error; err != nil {
// Omit ID to let PostgreSQL sequence auto-generate it
// Without this, GORM inserts ID=0 which causes duplicate key errors
if err := s.db.Omit("ID").Create(snapshot).Error; err != nil {
return fmt.Errorf("failed to save equity snapshot: %w", err)
}
return nil

View File

@@ -63,6 +63,10 @@ type GridConfigModel struct {
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
IsActive bool `json:"is_active" gorm:"default:false"`
// Direction adjustment settings
EnableDirectionAdjust bool `json:"enable_direction_adjust" gorm:"default:false"`
DirectionBiasRatio float64 `json:"direction_bias_ratio" gorm:"default:0.7"`
}
func (GridConfigModel) TableName() string {
@@ -108,6 +112,11 @@ type GridInstanceModel struct {
// Position adjustment due to breakout
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
// Grid direction adjustment state
CurrentDirection string `json:"current_direction" gorm:"default:neutral"`
DirectionChangedAt time.Time `json:"direction_changed_at"`
DirectionChangeCount int `json:"direction_change_count" gorm:"default:0"`
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
TotalFees float64 `json:"total_fees" gorm:"default:0"`
TotalTrades int `json:"total_trades" gorm:"default:0"`

View File

@@ -156,18 +156,22 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
newEntryPrice = math.Round(newEntryPrice*100) / 100
// Use 8 decimal places for price precision (crypto standard)
newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000
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 {
@@ -184,7 +188,26 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64
if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
newExitPrice = math.Round(newExitPrice*100) / 100
// Use 8 decimal places for price precision (crypto standard)
newExitPrice = math.Round(newExitPrice*100000000) / 100000000
}
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{}{
@@ -192,14 +215,17 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
"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

@@ -147,7 +147,8 @@ func (pb *PositionBuilder) handleClose(
var finalExitPrice float64
if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
finalExitPrice = math.Round(finalExitPrice*100) / 100
// Use 8 decimal places for price precision (crypto standard)
finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000
} else {
finalExitPrice = price
}

View File

@@ -81,6 +81,10 @@ type GridStrategyConfig struct {
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
// Use maker-only orders for lower fees
UseMakerOnly bool `json:"use_maker_only"`
// Enable automatic grid direction adjustment based on box breakouts
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
}
// PromptSectionsConfig editable sections of System Prompt
@@ -97,7 +101,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "mixed"
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -107,10 +111,14 @@ type CoinSourceConfig struct {
UseAI500 bool `json:"use_ai500"`
// AI500 coin pool maximum count
AI500Limit int `json:"ai500_limit,omitempty"`
// whether to use OI Top
// whether to use OI Top (持仓增加榜,适合做多)
UseOITop bool `json:"use_oi_top"`
// OI Top maximum count
OITopLimit int `json:"oi_top_limit,omitempty"`
// whether to use OI Low (持仓减少榜,适合做空)
UseOILow bool `json:"use_oi_low"`
// OI Low maximum count
OILowLimit int `json:"oi_low_limit,omitempty"`
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
}
@@ -248,7 +256,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
UseAI500: true,
AI500Limit: 10,
UseOITop: false,
OITopLimit: 20,
OITopLimit: 10,
UseOILow: false,
OILowLimit: 10,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{

View File

@@ -1,4 +1,4 @@
package trader
package aster
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package trader
package aster
import (
"context"
@@ -23,6 +23,7 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"nofx/trader/types"
)
// AsterTrader Aster trading platform implementation
@@ -1295,14 +1296,14 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
// GetClosedPnL gets recent closing trades from Aster
// Note: Aster does NOT have a position history API, only trade history.
// This returns individual closing trades for real-time position closure detection.
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
@@ -1330,7 +1331,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLR
}
}
records = append(records, ClosedPnLRecord{
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1366,7 +1367,7 @@ type AsterTradeRecord struct {
}
// GetTrades retrieves trade history from Aster
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 500
}
@@ -1381,24 +1382,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []TradeRecord{}, nil
return []types.TradeRecord{}, nil
}
var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []TradeRecord{}, nil
return []types.TradeRecord{}, nil
}
// Convert to unified TradeRecord format
var result []TradeRecord
var result []types.TradeRecord
for _, at := range asterTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Qty, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: at.Side,
@@ -1416,7 +1417,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
params := map[string]interface{}{
"symbol": symbol,
}
@@ -1442,13 +1443,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var result []OpenOrder
var result []types.OpenOrder
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: order.Side,
@@ -1466,7 +1467,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
}
// PlaceLimitOrder places a limit order for grid trading
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
if err != nil {
@@ -1532,7 +1533,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult
clientOrderID = cid
}
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: clientOrderID,
Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package trader
package aster
import (
"context"
@@ -10,6 +10,8 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -19,8 +21,8 @@ import (
// AsterTraderTestSuite Aster trader test suite
// Inherits TraderTestSuite and adds Aster specific mock logic
type AsterTraderTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewAsterTraderTestSuite creates Aster test suite
@@ -191,7 +193,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
privateKey, _ := crypto.GenerateKey()
// Create mock trader using mock server's URL
trader := &AsterTrader{
traderInstance := &AsterTrader{
ctx: context.Background(),
user: "0x1234567890123456789012345678901234567890",
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
@@ -202,7 +204,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
}
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &AsterTraderTestSuite{
TraderTestSuite: baseSuite,
@@ -224,7 +226,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
// TestAsterTrader_InterfaceCompliance tests interface compliance
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*AsterTrader)(nil)
var _ types.Trader = (*AsterTrader)(nil)
}
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
@@ -277,21 +279,21 @@ func TestNewAsterTrader(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
if tt.wantError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
assert.Nil(t, trader)
assert.Nil(t, at)
} else {
assert.NoError(t, err)
assert.NotNil(t, trader)
if trader != nil {
assert.Equal(t, tt.user, trader.user)
assert.Equal(t, tt.signer, trader.signer)
assert.NotNil(t, trader.privateKey)
assert.NotNil(t, at)
if at != nil {
assert.Equal(t, tt.user, at.user)
assert.Equal(t, tt.signer, at.signer)
assert.NotNil(t, at.privateKey)
}
}
})

View File

@@ -4,12 +4,21 @@ import (
"encoding/json"
"fmt"
"math"
"nofx/kernel"
"nofx/experience"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/hyperliquid"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"strings"
"sync"
"time"
@@ -23,7 +32,7 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
@@ -44,6 +53,15 @@ type AutoTraderConfig struct {
BitgetSecretKey string
BitgetPassphrase string
// Gate API configuration
GateAPIKey string
GateSecretKey string
// KuCoin API configuration
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinPassphrase string
// Hyperliquid configuration
HyperliquidPrivateKey string
HyperliquidWalletAddr string
@@ -224,25 +242,31 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
switch config.Exchange {
case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
case "gate":
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
case "kucoin":
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
}
@@ -254,7 +278,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
}
// Lighter only supports mainnet (testnet disabled)
trader, err = NewLighterTraderV2(
trader, err = lighter.NewLighterTraderV2(
config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex,
@@ -363,7 +387,7 @@ func (at *AutoTrader) Run() error {
// Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
}
@@ -371,7 +395,7 @@ func (at *AutoTrader) Run() error {
// Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
}
@@ -379,7 +403,7 @@ func (at *AutoTrader) Run() error {
// Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
}
@@ -387,7 +411,7 @@ func (at *AutoTrader) Run() error {
// Start OKX order sync if using OKX exchange
if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
}
@@ -395,7 +419,7 @@ func (at *AutoTrader) Run() error {
// Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
}
@@ -403,7 +427,7 @@ func (at *AutoTrader) Run() error {
// Start Aster order sync if using Aster exchange
if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
}
@@ -411,12 +435,28 @@ func (at *AutoTrader) Run() error {
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
// Start Gate order sync if using Gate exchange
if at.exchange == "gate" {
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
}
}
// Start KuCoin order sync if using KuCoin exchange
if at.exchange == "kucoin" {
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop()
@@ -535,8 +575,25 @@ func (at *AutoTrader) runCycle() error {
}
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
at.saveEquitySnapshot(ctx)
// 如果没有候选币种,记录但不报错
if len(ctx.CandidateCoins) == 0 {
logger.Infof(" No candidate coins available, skipping this cycle")
record.Success = true // 不是错误,只是没有候选币
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
record.AccountState = store.AccountSnapshot{
TotalBalance: ctx.Account.TotalEquity,
AvailableBalance: ctx.Account.AvailableBalance,
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
PositionCount: ctx.Account.PositionCount,
InitialBalance: at.initialBalance,
}
at.saveDecision(record)
return nil
}
logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
@@ -815,14 +872,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
}
// 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil {
return nil, fmt.Errorf("trader has no strategy engine configured")
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
} else {
coins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
// Log warning but don't fail - equity snapshot should still be saved
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
} else {
candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
}
}
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
}
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
// 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance
@@ -1044,7 +1106,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
}
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1161,7 +1223,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
}
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1260,7 +1322,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1324,7 +1386,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1920,7 +1982,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return
}

View File

@@ -65,14 +65,20 @@ type GridState struct {
// Current regime level
CurrentRegimeLevel string
// Grid direction adjustment
CurrentDirection market.GridDirection
DirectionChangedAt time.Time
DirectionChangeCount int
}
// NewGridState creates a new grid state
func NewGridState(config *store.GridStrategyConfig) *GridState {
return &GridState{
Config: config,
Levels: make([]kernel.GridLevelInfo, 0),
OrderBook: make(map[string]int),
Config: config,
Levels: make([]kernel.GridLevelInfo, 0),
OrderBook: make(map[string]int),
CurrentDirection: market.GridDirectionNeutral,
}
}
@@ -325,7 +331,17 @@ func (at *AutoTrader) checkBoxBreakout() error {
}
// Take action based on breakout level
action := getBreakoutAction(breakoutLevel)
// Use direction-aware action if enabled
enableDirectionAdjust := gridConfig.EnableDirectionAdjust
action := getBreakoutActionWithDirection(breakoutLevel, enableDirectionAdjust)
// If direction adjustment action, determine the new direction
if action == BreakoutActionAdjustDirection {
box, _ := market.GetBoxData(gridConfig.Symbol)
newDirection := determineGridDirection(box, at.gridState.CurrentDirection, breakoutLevel, direction)
return at.executeDirectionAdjustment(newDirection)
}
return at.executeBreakoutAction(action)
}
@@ -358,11 +374,38 @@ func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
logger.Infof("Failed to cancel orders: %v", err)
}
return at.closeAllPositions()
case BreakoutActionAdjustDirection:
// Direction adjustment is handled separately via executeDirectionAdjustment
// This case should not be reached, but handle gracefully
logger.Infof("Direction adjustment action received via executeBreakoutAction")
return nil
}
return nil
}
// executeDirectionAdjustment handles grid direction changes based on box breakout
func (at *AutoTrader) executeDirectionAdjustment(newDirection market.GridDirection) error {
at.gridState.mu.RLock()
oldDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock()
if oldDirection == newDirection {
return nil // No change needed
}
logger.Infof("[Grid] Direction adjustment: %s → %s", oldDirection, newDirection)
// Cancel existing orders before adjusting
if err := at.cancelAllGridOrders(); err != nil {
logger.Warnf("[Grid] Failed to cancel orders during direction adjustment: %v", err)
}
// Apply the new direction
return at.adjustGridDirection(newDirection)
}
// closeAllPositions closes all open positions for the grid symbol
func (at *AutoTrader) closeAllPositions() error {
gridConfig := at.config.StrategyConfig.GridConfig
@@ -410,10 +453,16 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
breakoutLevel := at.gridState.BreakoutLevel
isPaused := at.gridState.IsPaused
positionReduction := at.gridState.PositionReductionPct
currentDirection := at.gridState.CurrentDirection
at.gridState.mu.RUnlock()
// Only check if we had a breakout
if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
// Only check if we had a breakout or non-neutral direction
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) ||
positionReduction != 0 ||
isPaused ||
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
if !needsRecoveryCheck {
return nil
}
@@ -436,6 +485,18 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
at.gridState.mu.Unlock()
}
// Check for direction recovery toward neutral (if direction adjustment is enabled)
if gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral {
if shouldRecoverDirection(box, currentDirection) {
newDirection := determineRecoveryDirection(box.CurrentPrice, box, currentDirection)
if newDirection != currentDirection {
logger.Infof("[Grid] Direction recovery: %s → %s (price back in short box)",
currentDirection, newDirection)
at.adjustGridDirection(newDirection)
}
}
}
return nil
}
@@ -570,6 +631,128 @@ func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.G
}
at.gridState.Levels = levels
// Apply direction-based side assignment if enabled
if config.EnableDirectionAdjust {
at.applyGridDirection(currentPrice)
}
}
// applyGridDirection adjusts grid level sides based on the current direction
// This redistributes buy/sell levels according to the direction bias ratio
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// Calculate how many levels should be buy vs sell based on direction
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
// For long/long_bias: more buy levels
// For short/short_bias: more sell levels
switch direction {
case market.GridDirectionLong:
// 100% buy - all levels are buy
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
// 100% sell - all levels are sell
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
// Assign sides based on position relative to current price
// For long_bias: keep all below as buy, convert some above to buy
// For short_bias: keep all above as sell, convert some below to sell
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
// Level below or at current price
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
// Level above current price
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
direction, buyRatio*100)
}
// adjustGridDirection handles runtime direction adjustment when breakout is detected
func (at *AutoTrader) adjustGridDirection(newDirection market.GridDirection) error {
at.gridState.mu.Lock()
defer at.gridState.mu.Unlock()
oldDirection := at.gridState.CurrentDirection
if oldDirection == newDirection {
return nil // No change needed
}
at.gridState.CurrentDirection = newDirection
at.gridState.DirectionChangedAt = time.Now()
at.gridState.DirectionChangeCount++
logger.Infof("[Grid] Direction changed: %s → %s (change count: %d)",
oldDirection, newDirection, at.gridState.DirectionChangeCount)
// Get current price for recalculation
currentPrice, err := at.trader.GetMarketPrice(at.gridState.Config.Symbol)
if err != nil {
return fmt.Errorf("failed to get market price: %w", err)
}
// Reapply direction to grid levels
at.applyGridDirection(currentPrice)
return nil
}
// RunGridCycle executes one grid trading cycle
@@ -1370,6 +1553,85 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s
}
at.gridState.Levels = levels
// Apply direction-based side assignment if enabled (note: caller holds lock)
if config.EnableDirectionAdjust {
at.applyGridDirectionLocked(currentPrice)
}
}
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
config := at.gridState.Config
direction := at.gridState.CurrentDirection
// Get bias ratio from config, default to 0.7 (70%/30%)
biasRatio := config.DirectionBiasRatio
if biasRatio <= 0 || biasRatio > 1 {
biasRatio = 0.7
}
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
// For neutral: use price-based assignment (buy below, sell above)
if direction == market.GridDirectionNeutral {
for i := range at.gridState.Levels {
if at.gridState.Levels[i].Price <= currentPrice {
at.gridState.Levels[i].Side = "buy"
} else {
at.gridState.Levels[i].Side = "sell"
}
}
return
}
totalLevels := len(at.gridState.Levels)
targetBuyLevels := int(float64(totalLevels) * buyRatio)
switch direction {
case market.GridDirectionLong:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "buy"
}
case market.GridDirectionShort:
for i := range at.gridState.Levels {
at.gridState.Levels[i].Side = "sell"
}
case market.GridDirectionLongBias, market.GridDirectionShortBias:
buyCount := 0
sellCount := 0
for i := range at.gridState.Levels {
needMoreBuys := buyCount < targetBuyLevels
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
if at.gridState.Levels[i].Price <= currentPrice {
if needMoreBuys {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else {
at.gridState.Levels[i].Side = "sell"
sellCount++
}
} else {
if needMoreSells && direction == market.GridDirectionShortBias {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else if needMoreBuys && direction == market.GridDirectionLongBias {
at.gridState.Levels[i].Side = "buy"
buyCount++
} else if needMoreSells {
at.gridState.Levels[i].Side = "sell"
sellCount++
} else {
at.gridState.Levels[i].Side = "buy"
buyCount++
}
}
}
}
}
// GridRiskInfo contains risk information for frontend display
@@ -1397,6 +1659,11 @@ type GridRiskInfo struct {
BreakoutLevel string `json:"breakout_level"`
BreakoutDirection string `json:"breakout_direction"`
// Grid direction
CurrentGridDirection string `json:"current_grid_direction"`
DirectionChangeCount int `json:"direction_change_count"`
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
}
// GetGridRiskInfo returns current risk information for frontend display
@@ -1513,6 +1780,10 @@ func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
BreakoutLevel: at.gridState.BreakoutLevel,
BreakoutDirection: at.gridState.BreakoutDirection,
CurrentGridDirection: string(at.gridState.CurrentDirection),
DirectionChangeCount: at.gridState.DirectionChangeCount,
EnableDirectionAdjust: gridConfig.EnableDirectionAdjust,
}
}

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"context"
@@ -7,6 +7,7 @@ import (
"fmt"
"nofx/hook"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
@@ -718,7 +719,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
// PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
@@ -770,7 +771,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResu
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID,
Symbol: order.Symbol,
@@ -896,8 +897,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
@@ -913,7 +914,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
@@ -936,7 +937,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
@@ -1247,14 +1248,14 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
// Note: Binance does NOT have a position history API, only trade history.
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
// NOT suitable for historical position reconstruction - use only for matching recent closures.
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue // Skip opening trades
@@ -1283,7 +1284,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
}
}
records = append(records, ClosedPnLRecord{
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1304,7 +1305,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
// GetTrades retrieves trade history from Binance Futures using Income API
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1322,7 +1323,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
return nil, fmt.Errorf("failed to get income history: %w", err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 {
@@ -1331,7 +1332,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol,
RealizedPnL: pnl,
@@ -1347,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// GetTradesForSymbol retrieves trade history for a specific symbol
// This is more reliable than using Income API which may have delays
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1364,14 +1365,14 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),
@@ -1390,7 +1391,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
// This is used for incremental sync - only fetch new trades since last sync
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1407,14 +1408,14 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"encoding/json"
@@ -11,6 +11,8 @@ import (
"github.com/adshao/go-binance/v2/futures"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -20,8 +22,8 @@ import (
// BinanceFuturesTestSuite Binance Futures trader test suite
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
type BinanceFuturesTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewBinanceFuturesTestSuite Creates Binance Futures test suite
@@ -270,13 +272,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
client.HTTPClient = mockServer.Client()
// Create FuturesTrader
trader := &FuturesTrader{
traderInstance := &FuturesTrader{
client: client,
cacheDuration: 0, // disable cache for testing
}
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &BinanceFuturesTestSuite{
TraderTestSuite: baseSuite,
@@ -298,7 +300,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
// TestFuturesTrader_InterfaceCompliance tests interface compliance
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*FuturesTrader)(nil)
var _ types.Trader = (*FuturesTrader)(nil)
}
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
@@ -343,20 +345,20 @@ func TestNewFuturesTrader(t *testing.T) {
defer mockServer.Close()
// Test successful creation
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
// Modify client to use mock server
trader.client.BaseURL = mockServer.URL
trader.client.HTTPClient = mockServer.Client()
t1.client.BaseURL = mockServer.URL
t1.client.HTTPClient = mockServer.Client()
assert.NotNil(t, trader)
assert.NotNil(t, trader.client)
assert.Equal(t, 15*time.Second, trader.cacheDuration)
assert.NotNil(t, t1)
assert.NotNil(t, t1.client)
assert.Equal(t, 15*time.Second, t1.cacheDuration)
}
// TestCalculatePositionSize tests position size calculation
func TestCalculatePositionSize(t *testing.T) {
trader := &FuturesTrader{}
ft := &FuturesTrader{}
tests := []struct {
name string
@@ -394,7 +396,7 @@ func TestCalculatePositionSize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
})
}

View File

@@ -1,10 +1,11 @@
package trader
package binance
import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/store"
"nofx/trader/types"
"sort"
"strings"
"sync"
@@ -56,12 +57,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
}
}
// 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"))
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)
// Step 1: Get max trade IDs from local DB for incremental sync
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
@@ -100,18 +97,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
symbolMap[s] = true
}
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
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
}
// 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
}
}
@@ -122,21 +118,20 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync")
// Update last sync time even if no changes
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock()
// 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
return nil
}
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols)
// Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols)
var allTrades []TradeRecord
var allTrades []types.TradeRecord
var failedSymbols []string
apiCalls := 0
for _, symbol := range changedSymbols {
var trades []TradeRecord
var trades []types.TradeRecord
var queryErr error
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
@@ -158,17 +153,12 @@ 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
}
@@ -182,10 +172,12 @@ 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
}
@@ -280,7 +272,21 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
trade.Time.UTC().Format("01-02 15:04:05"))
}
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
// 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)
return nil
}

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"context"

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"nofx/store"

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"context"

View File

@@ -1,4 +1,4 @@
package trader
package bitget
import (
"encoding/json"
@@ -48,52 +48,82 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
return nil, fmt.Errorf("failed to get fill history: %w", err)
}
var resp struct {
FillList []struct {
TradeID string `json:"tradeId"`
Symbol string `json:"symbol"`
OrderID string `json:"orderId"`
Side string `json:"side"` // buy, sell
Price string `json:"price"` // Fill price
BaseVolume string `json:"baseVolume"` // Fill size in base currency
Fee string `json:"fee"` // Fee (negative for cost)
FeeCcy string `json:"feeCcy"` // Fee currency
Profit string `json:"profit"` // Realized PnL
CTime string `json:"cTime"` // Fill time (ms)
TradeSide string `json:"tradeSide"` // open, close
} `json:"fillList"`
// Bitget fill structure - supports both one-way and hedge mode
type BitgetFill struct {
TradeID string `json:"tradeId"`
Symbol string `json:"symbol"`
OrderID string `json:"orderId"`
Side string `json:"side"` // buy, sell
Price string `json:"price"` // Fill price
BaseVolume string `json:"baseVolume"` // Fill size in base currency
Profit string `json:"profit"` // Realized PnL
CTime string `json:"cTime"` // Fill time (ms)
TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close
FeeDetail []struct {
FeeCoin string `json:"feeCoin"`
TotalFee string `json:"totalFee"`
} `json:"feeDetail"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse fills: %w", err)
// Try parsing as wrapped response first (fillList field)
var wrappedResp struct {
FillList []BitgetFill `json:"fillList"`
}
trades := make([]BitgetTrade, 0, len(resp.FillList))
// Try direct array format (Bitget V2 API returns data as direct array)
var directFills []BitgetFill
for _, fill := range resp.FillList {
// Try wrapped format first
if err := json.Unmarshal(data, &wrappedResp); err == nil && len(wrappedResp.FillList) > 0 {
logger.Infof("🔍 Bitget: parsed as wrapped format, fillList count: %d", len(wrappedResp.FillList))
directFills = wrappedResp.FillList
} else {
// Try direct array format
if err := json.Unmarshal(data, &directFills); err != nil {
logger.Infof("⚠️ Bitget fill-history parse failed, raw: %s", string(data))
return nil, fmt.Errorf("failed to parse fills: %w", err)
}
logger.Infof("🔍 Bitget: parsed as direct array, fills count: %d", len(directFills))
}
trades := make([]BitgetTrade, 0, len(directFills))
for _, fill := range directFills {
fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
fee, _ := strconv.ParseFloat(fill.Fee, 64)
profit, _ := strconv.ParseFloat(fill.Profit, 64)
cTime, _ := strconv.ParseInt(fill.CTime, 10, 64)
// Extract fee from feeDetail array (Bitget V2 API)
var fee float64
var feeAsset string
if len(fill.FeeDetail) > 0 {
fee, _ = strconv.ParseFloat(fill.FeeDetail[0].TotalFee, 64)
feeAsset = fill.FeeDetail[0].FeeCoin
}
// Determine order action based on side and tradeSide
// Bitget one-way mode:
// - buy + open = open long
// - sell + open = open short
// - sell + close = close long
// - buy + close = close short
// Bitget one-way mode: buy_single (open long), sell_single (close long)
// Bitget hedge mode: open + buy = open_long, close + sell = close_long
orderAction := "open_long"
side := strings.ToLower(fill.Side)
tradeSide := strings.ToLower(fill.TradeSide)
if tradeSide == "open" {
// One-way position mode (buy_single/sell_single)
if tradeSide == "buy_single" {
orderAction = "open_long"
} else if tradeSide == "sell_single" {
orderAction = "close_long"
} else if tradeSide == "open" {
// Hedge mode: open
if side == "buy" {
orderAction = "open_long"
} else {
orderAction = "open_short"
}
} else if tradeSide == "close" {
// Hedge mode: close
if side == "sell" {
orderAction = "close_long"
} else {
@@ -108,8 +138,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
Side: fill.Side,
FillPrice: fillPrice,
FillQty: fillQty,
Fee: -fee, // Bitget returns negative fee
FeeAsset: fill.FeeCcy,
Fee: -fee, // Bitget returns negative fee, convert to positive
FeeAsset: feeAsset,
ExecTime: time.UnixMilli(cTime).UTC(),
ProfitLoss: profit,
OrderType: "MARKET",

View File

@@ -1,4 +1,4 @@
package trader
package bitget
import (
"bytes"
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"time"
"nofx/trader/types"
)
// Bitget API endpoints (V2)
@@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
}
// GetClosedPnL retrieves closed position PnL records
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1051,9 +1052,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
return nil, fmt.Errorf("failed to parse response: %w", err)
}
records := make([]ClosedPnLRecord, 0, len(resp.List))
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
for _, pos := range resp.List {
record := ClosedPnLRecord{
record := types.ClosedPnLRecord{
Symbol: pos.Symbol,
Side: pos.HoldSide,
}
@@ -1098,9 +1099,9 @@ func genBitgetClientOid() string {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
var result []OpenOrder
var result []types.OpenOrder
// 1. Get pending limit orders
params := map[string]interface{}{
@@ -1135,7 +1136,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: symbol,
Side: side,
@@ -1151,9 +1152,10 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
}
// 2. Get pending plan orders (stop-loss/take-profit)
// Bitget V2 API requires planType parameter: profit_loss for SL/TP orders
planParams := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"planType": "profit_loss",
}
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
@@ -1163,33 +1165,53 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
if err == nil && planData != nil {
var planOrders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PosSide string `json:"posSide"`
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
TriggerPrice string `json:"triggerPrice"`
Size string `json:"size"`
State string `json:"state"`
OrderId string `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PosSide string `json:"posSide"`
PlanType string `json:"planType"` // pos_loss, pos_profit
TriggerPrice string `json:"triggerPrice"`
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
Size string `json:"size"`
PlanStatus string `json:"planStatus"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(planData, &planOrders); err == nil {
for _, order := range planOrders.EntrustedList {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(order.Size, 64)
// Filter by symbol if specified
if symbol != "" && order.Symbol != symbol {
continue
}
// Determine trigger price based on plan type
var triggerPrice float64
orderType := "STOP_MARKET"
if order.PlanType == "pos_profit" {
// Take profit order
orderType = "TAKE_PROFIT_MARKET"
if order.StopSurplusTriggerPrice != "" {
triggerPrice, _ = strconv.ParseFloat(order.StopSurplusTriggerPrice, 64)
} else {
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
}
} else {
// Stop loss order (pos_loss)
if order.StopLossTriggerPrice != "" {
triggerPrice, _ = strconv.ParseFloat(order.StopLossTriggerPrice, 64)
} else {
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
}
}
quantity, _ := strconv.ParseFloat(order.Size, 64)
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
// Map Bitget plan type to order type
orderType := "STOP_MARKET"
if order.PlanType == "profit_plan" {
orderType = "TAKE_PROFIT_MARKET"
}
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: symbol,
Symbol: order.Symbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
@@ -1208,7 +1230,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
symbol := t.convertSymbol(req.Symbol)
// Set leverage if specified
@@ -1264,7 +1286,7 @@ func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResul
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
symbol, side, req.Price, order.OrderId)
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: order.OrderId,
ClientID: order.ClientOid,
Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package trader
package bybit
import (
"crypto/hmac"

View File

@@ -1,4 +1,4 @@
package trader
package bybit
import (
"context"
@@ -17,6 +17,7 @@ import (
"time"
bybit "github.com/bybit-exchange/bybit.go.api"
"nofx/trader/types"
)
// BybitTrader Bybit USDT Perpetual Futures Trader
@@ -900,13 +901,13 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
}
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit)
}
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
@@ -967,14 +968,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]Clo
}
// parseClosedPnLResult parses the closed PnL result from Bybit API
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
data, ok := resultData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
list, _ := data["list"].([]interface{})
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, item := range list {
pnl, ok := item.(map[string]interface{})
@@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
normalizedSide = "short"
}
record := ClosedPnLRecord{
record := types.ClosedPnLRecord{
Symbol: symbol,
Side: normalizedSide,
EntryPrice: avgEntryPrice,
@@ -1046,8 +1047,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
@@ -1088,7 +1089,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
displayType = stopOrderType
}
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
@@ -1108,7 +1109,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
@@ -1169,7 +1170,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID)
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package trader
package bybit
import (
"encoding/json"
@@ -9,6 +9,8 @@ import (
"time"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -18,8 +20,8 @@ import (
// BybitTraderTestSuite Bybit trader test suite
// Inherits TraderTestSuite and adds Bybit-specific mock logic
type BybitTraderTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewBybitTraderTestSuite Create Bybit test suite
@@ -66,10 +68,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
}))
// Create real Bybit trader (for interface compliance testing)
trader := NewBybitTrader("test_api_key", "test_secret_key")
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &BybitTraderTestSuite{
TraderTestSuite: baseSuite,
@@ -91,7 +93,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
// TestBybitTrader_InterfaceCompliance Test interface compliance
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*BybitTrader)(nil)
var _ types.Trader = (*BybitTrader)(nil)
}
// ============================================================
@@ -128,13 +130,13 @@ func TestNewBybitTrader(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader := NewBybitTrader(tt.apiKey, tt.secretKey)
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
if tt.wantNil {
assert.Nil(t, trader)
assert.Nil(t, bt)
} else {
assert.NotNil(t, trader)
assert.NotNil(t, trader.client)
assert.NotNil(t, bt)
assert.NotNil(t, bt.client)
}
})
}
@@ -176,7 +178,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
// TestBybitTrader_FormatQuantity Test quantity formatting
func TestBybitTrader_FormatQuantity(t *testing.T) {
trader := NewBybitTrader("test", "test")
bt := NewBybitTrader("test", "test")
tests := []struct {
name string
@@ -210,7 +212,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := trader.FormatQuantity(tt.symbol, tt.quantity)
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
if tt.hasError {
assert.Error(t, err)
} else {
@@ -335,19 +337,19 @@ func convertBybitSide(side string) string {
// TestBybitTrader_CategoryLinear Test using only linear category
func TestBybitTrader_CategoryLinear(t *testing.T) {
// Bybit trader should only use linear category (USDT perpetual contracts)
trader := NewBybitTrader("test", "test")
assert.NotNil(t, trader)
bt := NewBybitTrader("test", "test")
assert.NotNil(t, bt)
// Verify default configuration
assert.NotNil(t, trader.client)
assert.NotNil(t, bt.client)
}
// TestBybitTrader_CacheDuration Test cache duration
func TestBybitTrader_CacheDuration(t *testing.T) {
trader := NewBybitTrader("test", "test")
bt := NewBybitTrader("test", "test")
// Verify default cache time is 15 seconds
assert.Equal(t, 15*time.Second, trader.cacheDuration)
assert.Equal(t, 15*time.Second, bt.cacheDuration)
}
// ============================================================

282
trader/gate/order_sync.go Normal file
View File

@@ -0,0 +1,282 @@
package gate
import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/store"
"sort"
"strconv"
"strings"
"time"
"github.com/antihax/optional"
"github.com/gateio/gateapi-go/v6"
)
// GateTrade represents a trade record from Gate fill history
type GateTrade struct {
Symbol string
TradeID string
OrderID string
Side string // buy or sell
FillPrice float64
FillQty float64 // In base currency (e.g., ETH), not contracts
Fee float64
FeeAsset string
ExecTime time.Time
ProfitLoss float64
OrderType string
OrderAction string // open_long, open_short, close_long, close_short
}
// GetTrades retrieves trade/fill records from Gate
func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100 // Gate max limit
}
opts := &gateapi.GetMyTradesOpts{
Limit: optional.NewInt32(int32(limit)),
}
// Get trades from Gate API
trades, _, err := t.client.FuturesApi.GetMyTrades(t.ctx, "usdt", opts)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
logger.Infof("📥 Received %d trades from Gate", len(trades))
result := make([]GateTrade, 0, len(trades))
for _, trade := range trades {
// Filter by start time
createTime := int64(trade.CreateTime)
if createTime < startTime.Unix() {
continue
}
fillPrice, _ := strconv.ParseFloat(trade.Price, 64)
// Get quanto_multiplier for this contract to convert size to base currency
quantoMultiplier := 1.0
contract, err := t.getContract(trade.Contract)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Convert contract size to actual quantity
absSize := trade.Size
if absSize < 0 {
absSize = -absSize
}
fillQty := float64(absSize) * quantoMultiplier
// Determine side and order action based on size and close_size
// Gate close_size field determines if trade is opening or closing:
// close_size=0 && size>0: Open long
// close_size=0 && size<0: Open short
// close_size>0 && size>0: Close short (and possibly open long if size > close_size)
// close_size<0 && size<0: Close long (and possibly open short if |size| > |close_size|)
side := "BUY"
orderAction := "open_long"
if trade.Size > 0 {
side = "BUY"
if trade.CloseSize > 0 {
// Closing short position
orderAction = "close_short"
} else {
// Opening long position
orderAction = "open_long"
}
} else if trade.Size < 0 {
side = "SELL"
if trade.CloseSize < 0 {
// Closing long position
orderAction = "close_long"
} else {
// Opening short position
orderAction = "open_short"
}
}
// Calculate fee (Gate returns fee as negative value)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
if fee < 0 {
fee = -fee
}
// For closed positions, estimate PnL (Gate doesn't directly provide it in trade record)
pnl := 0.0
if strings.Contains(orderAction, "close") {
// PnL would need to be calculated from position history
// For now, we leave it as 0 and let position builder handle it
}
gateTrade := GateTrade{
Symbol: trade.Contract,
TradeID: fmt.Sprintf("%d", trade.Id),
OrderID: trade.OrderId,
Side: side,
FillPrice: fillPrice,
FillQty: fillQty,
Fee: fee,
FeeAsset: "USDT",
ExecTime: time.Unix(createTime, 0).UTC(),
ProfitLoss: pnl,
OrderType: "MARKET",
OrderAction: orderAction,
}
result = append(result, gateTrade)
}
return result, nil
}
// SyncOrdersFromGate syncs Gate exchange order history to local database
// Also creates/updates position records to ensure orders/fills/positions data consistency
// exchangeID: Exchange account UUID (from exchanges.id)
// exchangeType: Exchange type ("gate")
func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
if st == nil {
return fmt.Errorf("store is nil")
}
// Get recent trades (last 24 hours)
startTime := time.Now().Add(-24 * time.Hour)
logger.Infof("🔄 Syncing Gate trades from: %s", startTime.Format(time.RFC3339))
// Use GetTrades method to fetch trade records
trades, err := t.GetTrades(startTime, 100)
if err != nil {
return fmt.Errorf("failed to get trades: %w", err)
}
logger.Infof("📥 Received %d trades from Gate", len(trades))
// Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
})
// Process trades one by one (no transaction to avoid deadlock)
orderStore := st.Order()
positionStore := st.Position()
posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0
for _, trade := range trades {
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
continue // Order already exists, skip
}
// Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT)
symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", ""))
// Determine position side from order action
positionSide := "LONG"
if strings.Contains(trade.OrderAction, "short") {
positionSide = "SHORT"
}
// Normalize side for storage
side := strings.ToUpper(trade.Side)
// Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: trade.TradeID,
Symbol: symbol,
Side: side,
PositionSide: "BOTH", // Gate uses one-way position mode
Type: trade.OrderType,
OrderAction: trade.OrderAction,
Quantity: trade.FillQty,
Price: trade.FillPrice,
Status: "FILLED",
FilledQuantity: trade.FillQty,
AvgFillPrice: trade.FillPrice,
Commission: trade.Fee,
FilledAt: execTimeMs,
CreatedAt: execTimeMs,
UpdatedAt: execTimeMs,
}
// Insert order record
if err := orderStore.CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
continue
}
// Create fill record - use UTC time in milliseconds
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type
OrderID: orderRecord.ID,
ExchangeOrderID: trade.OrderID,
ExchangeTradeID: trade.TradeID,
Symbol: symbol,
Side: side,
Price: trade.FillPrice,
Quantity: trade.FillQty,
QuoteQuantity: trade.FillPrice * trade.FillQty,
Commission: trade.Fee,
CommissionAsset: trade.FeeAsset,
RealizedPnL: trade.ProfitLoss,
IsMaker: false,
CreatedAt: execTimeMs,
}
if err := orderStore.CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
}
// Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
execTimeMs, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty)
}
syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
}
logger.Infof("✅ Gate order sync completed: %d new trades synced", syncedCount)
return nil
}
// StartOrderSync starts background order sync task for Gate
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Gate order sync failed: %v", err)
}
}
}()
logger.Infof("🔄 Gate order sync started (interval: %v)", interval)
}

898
trader/gate/trader.go Normal file
View File

@@ -0,0 +1,898 @@
package gate
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"sync"
"time"
"github.com/antihax/optional"
"github.com/gateio/gateapi-go/v6"
"nofx/logger"
"nofx/trader/types"
)
// GateTrader implements types.Trader interface for Gate.io Futures
type GateTrader struct {
apiKey string
secretKey string
client *gateapi.APIClient
ctx context.Context
// Cache fields
cachedBalance map[string]interface{}
balanceCacheTime time.Time
balanceCacheMutex sync.RWMutex
cachedPositions []map[string]interface{}
positionsCacheTime time.Time
positionsCacheMutex sync.RWMutex
contractsCache map[string]*gateapi.Contract
contractsCacheMutex sync.RWMutex
cacheDuration time.Duration
}
// NewGateTrader creates a new Gate trader instance
func NewGateTrader(apiKey, secretKey string) *GateTrader {
config := gateapi.NewConfiguration()
config.AddDefaultHeader("X-Gate-Channel-Id", "nofx")
client := gateapi.NewAPIClient(config)
ctx := context.WithValue(context.Background(),
gateapi.ContextGateAPIV4,
gateapi.GateAPIV4{
Key: apiKey,
Secret: secretKey,
},
)
return &GateTrader{
apiKey: apiKey,
secretKey: secretKey,
client: client,
ctx: ctx,
contractsCache: make(map[string]*gateapi.Contract),
cacheDuration: 15 * time.Second,
}
}
// GetBalance retrieves account balance
func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.balanceCacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.balanceCacheMutex.RUnlock()
return cached, nil
}
t.balanceCacheMutex.RUnlock()
// Fetch from API
accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt")
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
total, _ := strconv.ParseFloat(accounts.Total, 64)
available, _ := strconv.ParseFloat(accounts.Available, 64)
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64)
result := map[string]interface{}{
"totalWalletBalance": total,
"availableBalance": available,
"totalUnrealizedProfit": unrealizedPnl,
}
// Update cache
t.balanceCacheMutex.Lock()
t.cachedBalance = result
t.balanceCacheTime = time.Now()
t.balanceCacheMutex.Unlock()
return result, nil
}
// GetPositions retrieves all open positions
func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.positionsCacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.positionsCacheMutex.RUnlock()
return cached, nil
}
t.positionsCacheMutex.RUnlock()
// Fetch from API
positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result []map[string]interface{}
for _, pos := range positions {
if pos.Size == 0 {
continue // Skip empty positions
}
entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64)
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64)
unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64)
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
// Gate returns position size in contracts, need to convert to base currency
// Each contract = quanto_multiplier base currency
contractSize := float64(pos.Size)
if pos.Size < 0 {
contractSize = float64(-pos.Size)
}
// Get quanto_multiplier from contract info to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(pos.Contract)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Convert contract count to actual token quantity
positionAmt := contractSize * quantoMultiplier
// Determine side based on position size
side := "long"
if pos.Size < 0 {
side = "short"
}
result = append(result, map[string]interface{}{
"symbol": pos.Contract,
"positionAmt": positionAmt,
"entryPrice": entryPrice,
"markPrice": markPrice,
"unRealizedProfit": unrealizedPnl,
"leverage": int(leverage),
"liquidationPrice": liqPrice,
"side": side,
})
}
// Update cache
t.positionsCacheMutex.Lock()
t.cachedPositions = result
t.positionsCacheTime = time.Now()
t.positionsCacheMutex.Unlock()
return result, nil
}
// convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT)
func (t *GateTrader) convertSymbol(symbol string) string {
// If already in correct format
if strings.Contains(symbol, "_") {
return symbol
}
// Convert BTCUSDT to BTC_USDT
if strings.HasSuffix(symbol, "USDT") {
base := strings.TrimSuffix(symbol, "USDT")
return base + "_USDT"
}
return symbol
}
// revertSymbol converts symbol back to standard format (e.g., BTC_USDT -> BTCUSDT)
func (t *GateTrader) revertSymbol(symbol string) string {
return strings.ReplaceAll(symbol, "_", "")
}
// getContract fetches contract info with caching
func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) {
symbol = t.convertSymbol(symbol)
// Check cache
t.contractsCacheMutex.RLock()
if contract, ok := t.contractsCache[symbol]; ok {
t.contractsCacheMutex.RUnlock()
return contract, nil
}
t.contractsCacheMutex.RUnlock()
// Fetch from API
contract, _, err := t.client.FuturesApi.GetFuturesContract(t.ctx, "usdt", symbol)
if err != nil {
return nil, fmt.Errorf("failed to get contract info: %w", err)
}
// Update cache
t.contractsCacheMutex.Lock()
t.contractsCache[symbol] = &contract
t.contractsCacheMutex.Unlock()
return &contract, nil
}
// SetLeverage sets the leverage for a symbol
func (t *GateTrader) SetLeverage(symbol string, leverage int) error {
symbol = t.convertSymbol(symbol)
_, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil)
if err != nil {
// Gate.io may return error if leverage is already set
if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") {
logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol)
return nil
}
return fmt.Errorf("failed to set leverage: %w", err)
}
logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol)
return nil
}
// SetMarginMode sets margin mode (cross or isolated)
func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
// Gate.io uses leverage=0 for cross margin, positive number for isolated
// This is handled through UpdatePositionLeverage with cross_leverage_limit
// For now, we'll skip explicit margin mode setting as it's tied to leverage
logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)")
return nil
}
// OpenLong opens a long position
func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units (each contract = quanto_multiplier base currency)
// size = quantity / quanto_multiplier
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive for long
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// OpenShort opens a short position
func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// Cancel old orders first
t.CancelAllOrders(symbol)
// Set leverage
if err := t.SetLeverage(symbol, leverage); err != nil {
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
// Gate uses contract size units
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative for short
Price: "0", // Market order
Tif: "ioc",
Text: "t-nofx",
}
logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to open short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseLong closes a long position
func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "long" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("long position not found for %s", symbol)
}
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close long = sell (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: -size, // Negative to close long
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close long position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// CloseShort closes a short position
func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
// If quantity is 0, get current position
if quantity == 0 {
positions, err := t.GetPositions()
if err != nil {
return nil, err
}
for _, pos := range positions {
posSymbol := t.convertSymbol(pos["symbol"].(string))
if posSymbol == symbol && pos["side"] == "short" {
quantity = pos["positionAmt"].(float64)
break
}
}
if quantity == 0 {
return nil, fmt.Errorf("short position not found for %s", symbol)
}
}
// Ensure quantity is positive
if quantity < 0 {
quantity = -quantity
}
// Get contract info for size calculation
contract, err := t.getContract(symbol)
if err != nil {
return nil, err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// Close short = buy (use ReduceOnly, not Close which requires Size=0)
order := gateapi.FuturesOrder{
Contract: symbol,
Size: size, // Positive to close short
Price: "0",
Tif: "ioc",
ReduceOnly: true,
Text: "t-nofx-close",
}
logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size)
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
if err != nil {
return nil, fmt.Errorf("failed to close short position: %w", err)
}
// Clear cache
t.clearCache()
// Parse fill price from result
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
return map[string]interface{}{
"orderId": fmt.Sprintf("%d", result.Id),
"symbol": t.revertSymbol(symbol),
"status": "FILLED",
"fillPrice": fillPrice,
"avgPrice": fillPrice,
}, nil
}
// GetMarketPrice gets the current market price
func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesTickersOpts{
Contract: optional.NewString(symbol),
}
tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts)
if err != nil {
return 0, fmt.Errorf("failed to get market price: %w", err)
}
if len(tickers) == 0 {
return 0, fmt.Errorf("no ticker data for %s", symbol)
}
price, _ := strconv.ParseFloat(tickers[0].Last, 64)
return price, nil
}
// SetStopLoss sets a stop loss order
func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, stop loss means sell when price drops
// For short position, stop loss means buy when price rises
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
// Use price trigger order
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", stopPrice),
Rule: 1, // Price <= trigger price
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set stop loss: %w", err)
}
logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice)
return nil
}
// SetTakeProfit sets a take profit order
func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
symbol = t.convertSymbol(symbol)
contract, err := t.getContract(symbol)
if err != nil {
return err
}
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
size := int64(quantity / quantoMultiplier)
if size <= 0 {
size = 1
}
// For long position, take profit means sell when price rises
// For short position, take profit means buy when price drops
if strings.ToUpper(positionSide) == "LONG" {
size = -size
}
trigger := gateapi.FuturesPriceTriggeredOrder{
Initial: gateapi.FuturesInitialOrder{
Contract: symbol,
Size: size,
Price: "0", // Market order
Tif: "ioc",
ReduceOnly: true,
Close: true,
},
Trigger: gateapi.FuturesPriceTrigger{
StrategyType: 0, // Close position
PriceType: 0, // Latest price
Price: fmt.Sprintf("%.8f", takeProfitPrice),
Rule: 2, // Price >= trigger price for long take profit
},
}
if strings.ToUpper(positionSide) == "SHORT" {
trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit
}
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
if err != nil {
return fmt.Errorf("failed to set take profit: %w", err)
}
logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
return nil
}
// CancelStopLossOrders cancels stop loss orders
func (t *GateTrader) CancelStopLossOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "stop_loss")
}
// CancelTakeProfitOrders cancels take profit orders
func (t *GateTrader) CancelTakeProfitOrders(symbol string) error {
return t.cancelTriggerOrders(symbol, "take_profit")
}
// cancelTriggerOrders cancels trigger orders of a specific type
func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return err
}
for _, order := range orders {
// Determine if it's stop loss or take profit based on trigger rule and position
// For simplicity, cancel all matching symbol orders
_, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id))
if err != nil {
logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err)
}
}
return nil
}
// CancelAllOrders cancels all pending orders for a symbol
func (t *GateTrader) CancelAllOrders(symbol string) error {
symbol = t.convertSymbol(symbol)
// Cancel regular orders
_, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil)
if err != nil {
// Ignore if no orders to cancel
if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") {
logger.Warnf(" [Gate] Error canceling orders: %v", err)
}
}
// Cancel trigger orders
t.cancelTriggerOrders(symbol, "")
return nil
}
// CancelStopOrders cancels all stop orders (stop loss and take profit)
func (t *GateTrader) CancelStopOrders(symbol string) error {
t.CancelStopLossOrders(symbol)
t.CancelTakeProfitOrders(symbol)
return nil
}
// FormatQuantity formats quantity to correct precision
func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
contract, err := t.getContract(symbol)
if err != nil {
return fmt.Sprintf("%.4f", quantity), nil
}
// Gate uses quanto_multiplier for contract size
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if quantoMultiplier > 0 {
// Calculate number of contracts
numContracts := quantity / quantoMultiplier
return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil
}
return fmt.Sprintf("%.4f", quantity), nil
}
// GetOrderStatus gets the status of an order
func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
symbol = t.convertSymbol(symbol)
order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64)
tkFee, _ := strconv.ParseFloat(order.Tkfr, 64)
mkFee, _ := strconv.ParseFloat(order.Mkfr, 64)
totalFee := tkFee + mkFee
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, contractErr := t.getContract(symbol)
if contractErr == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
// Map status
status := "NEW"
switch order.Status {
case "finished":
if order.FinishAs == "filled" {
status = "FILLED"
} else if order.FinishAs == "cancelled" {
status = "CANCELED"
} else {
status = "CLOSED"
}
case "open":
status = "NEW"
}
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier
return map[string]interface{}{
"orderId": orderID,
"symbol": t.revertSymbol(symbol),
"status": status,
"avgPrice": fillPrice,
"executedQty": executedQty,
"side": side,
"type": order.Tif,
"time": int64(order.CreateTime * 1000),
"updateTime": int64(order.FinishTime * 1000),
"commission": totalFee,
}, nil
}
// GetClosedPnL retrieves closed position PnL records
func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100
}
opts := &gateapi.ListPositionCloseOpts{
Limit: optional.NewInt32(int32(limit)),
From: optional.NewInt64(startTime.Unix()),
}
closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts)
if err != nil {
return nil, fmt.Errorf("failed to get closed positions: %w", err)
}
records := make([]types.ClosedPnLRecord, 0, len(closedPositions))
for _, pos := range closedPositions {
pnl, _ := strconv.ParseFloat(pos.Pnl, 64)
record := types.ClosedPnLRecord{
Symbol: t.revertSymbol(pos.Contract),
Side: pos.Side,
RealizedPnL: pnl,
ExitTime: time.Unix(int64(pos.Time), 0).UTC(),
CloseType: "unknown",
}
records = append(records, record)
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
opts := &gateapi.ListFuturesOrdersOpts{
Contract: optional.NewString(symbol),
}
orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
// Get quanto_multiplier to convert contracts to actual quantity
quantoMultiplier := 1.0
contract, err := t.getContract(symbol)
if err == nil && contract != nil {
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
if qm > 0 {
quantoMultiplier = qm
}
}
var result []types.OpenOrder
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Size < 0 {
side = "SELL"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Contract),
Side: side,
Type: "LIMIT",
Price: price,
Quantity: quantity,
Status: "NEW",
})
}
// Also get trigger orders
triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{
Contract: optional.NewString(symbol),
}
triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts)
if err == nil {
for _, order := range triggerOrders {
triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64)
side := "BUY"
if order.Initial.Size < 0 {
side = "SELL"
}
orderType := "STOP_MARKET"
if order.Trigger.Rule == 2 {
orderType = "TAKE_PROFIT_MARKET"
}
// Convert contract count to actual token quantity
quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Id),
Symbol: t.revertSymbol(order.Initial.Contract),
Side: side,
Type: orderType,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// clearCache clears all caches
func (t *GateTrader) clearCache() {
t.balanceCacheMutex.Lock()
t.cachedBalance = nil
t.balanceCacheMutex.Unlock()
t.positionsCacheMutex.Lock()
t.cachedPositions = nil
t.positionsCacheMutex.Unlock()
}
// Ensure GateTrader implements Trader interface
var _ types.Trader = (*GateTrader)(nil)

337
trader/gate/trader_test.go Normal file
View File

@@ -0,0 +1,337 @@
package gate
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
// Part 1: GateTraderTestSuite - Inherits base test suite
// ============================================================
// GateTraderTestSuite Gate trader test suite
// Inherits TraderTestSuite and adds Gate-specific mock logic
type GateTraderTestSuite struct {
*testutil.TraderTestSuite
mockServer *httptest.Server
}
// NewGateTraderTestSuite creates Gate test suite with mock server
func NewGateTraderTestSuite(t *testing.T) *GateTraderTestSuite {
// Create mock HTTP server
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
var respBody interface{}
switch {
// Mock GetBalance - /api/v4/futures/usdt/accounts
case strings.Contains(path, "/futures/usdt/accounts"):
respBody = map[string]interface{}{
"total": "10000.00",
"unrealised_pnl": "100.50",
"available": "8000.00",
"currency": "USDT",
}
// Mock GetPositions - /api/v4/futures/usdt/positions
case strings.Contains(path, "/futures/usdt/positions"):
respBody = []map[string]interface{}{
{
"contract": "BTC_USDT",
"size": 500,
"entry_price": "50000.00",
"mark_price": "50500.00",
"unrealised_pnl": "250.00",
"liq_price": "45000.00",
"leverage": "10",
},
}
// Mock GetContract - /api/v4/futures/usdt/contracts/{contract}
case strings.Contains(path, "/futures/usdt/contracts/"):
respBody = map[string]interface{}{
"name": "BTC_USDT",
"quanto_multiplier": "0.001",
"order_price_round": "0.1",
}
// Mock ListFuturesContracts - /api/v4/futures/usdt/contracts
case strings.Contains(path, "/futures/usdt/contracts"):
respBody = []map[string]interface{}{
{
"name": "BTC_USDT",
"quanto_multiplier": "0.001",
"order_price_round": "0.1",
},
{
"name": "ETH_USDT",
"quanto_multiplier": "0.01",
"order_price_round": "0.01",
},
}
// Mock ListFuturesTickers - /api/v4/futures/usdt/tickers
case strings.Contains(path, "/futures/usdt/tickers"):
contract := r.URL.Query().Get("contract")
if contract == "" {
contract = "BTC_USDT"
}
price := "50000.00"
if contract == "ETH_USDT" {
price = "3000.00"
}
respBody = []map[string]interface{}{
{
"contract": contract,
"last": price,
},
}
// Mock CreateFuturesOrder - /api/v4/futures/usdt/orders (POST)
case strings.Contains(path, "/futures/usdt/orders") && r.Method == "POST":
respBody = map[string]interface{}{
"id": 123456,
"contract": "BTC_USDT",
"size": 100,
"status": "finished",
"finish_as": "filled",
"fill_price": "50000.00",
}
// Mock ListFuturesOrders - /api/v4/futures/usdt/orders
case strings.Contains(path, "/futures/usdt/orders"):
respBody = []map[string]interface{}{}
// Mock GetFuturesOrder - /api/v4/futures/usdt/orders/{order_id}
case strings.Contains(path, "/futures/usdt/orders/"):
respBody = map[string]interface{}{
"id": 123456,
"contract": "BTC_USDT",
"size": 100,
"status": "finished",
"finish_as": "filled",
"fill_price": "50000.00",
"create_time": 1234567890.0,
"update_time": 1234567890.0,
"tkfr": "0.0005",
"mkfr": "0.0002",
}
// Mock UpdatePositionLeverage
case strings.Contains(path, "/futures/usdt/positions/") && strings.Contains(path, "/leverage"):
respBody = map[string]interface{}{
"leverage": 10,
}
// Mock ListPriceTriggeredOrders
case strings.Contains(path, "/futures/usdt/price_orders"):
respBody = []map[string]interface{}{}
// Mock ListPositionClose
case strings.Contains(path, "/futures/usdt/position_close"):
respBody = []map[string]interface{}{}
// Default: empty response
default:
respBody = map[string]interface{}{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
}))
// Create trader instance (will need to override URL in actual usage)
traderInstance := NewGateTrader("test_api_key", "test_secret_key")
// Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &GateTraderTestSuite{
TraderTestSuite: baseSuite,
mockServer: mockServer,
}
}
// Cleanup cleans up resources
func (s *GateTraderTestSuite) Cleanup() {
if s.mockServer != nil {
s.mockServer.Close()
}
s.TraderTestSuite.Cleanup()
}
// ============================================================
// Part 2: Interface compliance tests
// ============================================================
// TestGateTrader_InterfaceCompliance tests interface compliance
func TestGateTrader_InterfaceCompliance(t *testing.T) {
var _ types.Trader = (*GateTrader)(nil)
}
// ============================================================
// Part 3: Gate-specific feature unit tests
// ============================================================
// TestNewGateTrader tests creating Gate trader
func TestNewGateTrader(t *testing.T) {
tests := []struct {
name string
apiKey string
secretKey string
wantNil bool
}{
{
name: "Successfully create",
apiKey: "test_api_key",
secretKey: "test_secret_key",
wantNil: false,
},
{
name: "Empty API Key can still create",
apiKey: "",
secretKey: "test_secret_key",
wantNil: false,
},
{
name: "Empty Secret Key can still create",
apiKey: "test_api_key",
secretKey: "",
wantNil: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gt := NewGateTrader(tt.apiKey, tt.secretKey)
if tt.wantNil {
assert.Nil(t, gt)
} else {
assert.NotNil(t, gt)
assert.NotNil(t, gt.client)
assert.Equal(t, tt.apiKey, gt.apiKey)
assert.Equal(t, tt.secretKey, gt.secretKey)
}
})
}
}
// TestGateTrader_SymbolConversion tests symbol format conversion
func TestGateTrader_SymbolConversion(t *testing.T) {
gt := NewGateTrader("test", "test")
tests := []struct {
name string
input string
expected string
}{
{
name: "BTCUSDT to BTC_USDT",
input: "BTCUSDT",
expected: "BTC_USDT",
},
{
name: "ETHUSDT to ETH_USDT",
input: "ETHUSDT",
expected: "ETH_USDT",
},
{
name: "Already converted format",
input: "BTC_USDT",
expected: "BTC_USDT",
},
{
name: "SOL symbol",
input: "SOLUSDT",
expected: "SOL_USDT",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gt.convertSymbol(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestGateTrader_RevertSymbol tests symbol reversion
func TestGateTrader_RevertSymbol(t *testing.T) {
gt := NewGateTrader("test", "test")
tests := []struct {
name string
input string
expected string
}{
{
name: "BTC_USDT to BTCUSDT",
input: "BTC_USDT",
expected: "BTCUSDT",
},
{
name: "ETH_USDT to ETHUSDT",
input: "ETH_USDT",
expected: "ETHUSDT",
},
{
name: "Already standard format",
input: "BTCUSDT",
expected: "BTCUSDT",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := gt.revertSymbol(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// TestGateTrader_CacheDuration tests cache duration
func TestGateTrader_CacheDuration(t *testing.T) {
gt := NewGateTrader("test", "test")
// Verify default cache time is 15 seconds
assert.Equal(t, 15*time.Second, gt.cacheDuration)
}
// TestGateTrader_ClearCache tests cache clearing
func TestGateTrader_ClearCache(t *testing.T) {
gt := NewGateTrader("test", "test")
// Set some cached data
gt.cachedBalance = map[string]interface{}{"test": "data"}
gt.cachedPositions = []map[string]interface{}{{"test": "data"}}
// Clear cache
gt.clearCache()
// Verify cache is cleared
assert.Nil(t, gt.cachedBalance)
assert.Nil(t, gt.cachedPositions)
}
// ============================================================
// Part 4: Mock server integration tests
// ============================================================
// TestGateTrader_MockServerResponseFormat tests mock server response format
func TestGateTrader_MockServerResponseFormat(t *testing.T) {
suite := NewGateTraderTestSuite(t)
defer suite.Cleanup()
// Verify mock server is running
assert.NotNil(t, suite.mockServer)
assert.NotEmpty(t, suite.mockServer.URL)
}

View File

@@ -194,3 +194,119 @@ func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
return BreakoutActionNone
}
}
// ============================================================================
// Task 10: Grid Direction Adjustment
// ============================================================================
const (
// BreakoutActionAdjustDirection adjusts grid direction based on breakout
BreakoutActionAdjustDirection BreakoutAction = 4
)
// determineGridDirection determines the new grid direction based on box breakout
// currentDirection: the current grid direction
// breakoutLevel: which box level has been broken (short/mid/long)
// direction: breakout direction ("up" or "down")
// Returns: the new grid direction
func determineGridDirection(box *market.BoxData, currentDirection market.GridDirection, breakoutLevel market.BreakoutLevel, direction string) market.GridDirection {
if box == nil {
return currentDirection
}
price := box.CurrentPrice
switch breakoutLevel {
case market.BreakoutShort:
// Short box breakout: bias direction
// Still within mid box, so not a full trend yet
if direction == "up" {
return market.GridDirectionLongBias
}
return market.GridDirectionShortBias
case market.BreakoutMid:
// Mid box breakout: full direction
// More significant move, commit fully
if direction == "up" {
return market.GridDirectionLong
}
return market.GridDirectionShort
case market.BreakoutLong:
// Long box breakout: handled by existing emergency logic
// Return current direction, let existing handlers take over
return currentDirection
case market.BreakoutNone:
// No breakout - check if we should recover toward neutral
return determineRecoveryDirection(price, box, currentDirection)
default:
return currentDirection
}
}
// determineRecoveryDirection determines if grid direction should recover toward neutral
// This implements the gradual recovery logic: long → long_bias → neutral ← short_bias ← short
func determineRecoveryDirection(price float64, box *market.BoxData, currentDirection market.GridDirection) market.GridDirection {
// Check if price is back inside the short box
insideShortBox := price >= box.ShortLower && price <= box.ShortUpper
if !insideShortBox {
// Still outside short box, maintain current direction
return currentDirection
}
// Price is inside short box, start recovery toward neutral
switch currentDirection {
case market.GridDirectionLong:
// Full long → bias long
return market.GridDirectionLongBias
case market.GridDirectionLongBias:
// Bias long → neutral
return market.GridDirectionNeutral
case market.GridDirectionShort:
// Full short → bias short
return market.GridDirectionShortBias
case market.GridDirectionShortBias:
// Bias short → neutral
return market.GridDirectionNeutral
default:
return currentDirection
}
}
// getBreakoutActionWithDirection returns the appropriate action for a breakout level
// when direction adjustment is enabled
func getBreakoutActionWithDirection(level market.BreakoutLevel, enableDirectionAdjust bool) BreakoutAction {
if !enableDirectionAdjust {
// Fall back to original behavior
return getBreakoutAction(level)
}
switch level {
case market.BreakoutShort:
// Short box breakout with direction adjustment: adjust direction instead of reducing position
return BreakoutActionAdjustDirection
case market.BreakoutMid:
// Mid box breakout with direction adjustment: adjust to full direction
return BreakoutActionAdjustDirection
case market.BreakoutLong:
// Long box breakout: always trigger emergency handling
return BreakoutActionCloseAll
default:
return BreakoutActionNone
}
}
// shouldRecoverDirection checks if the current grid direction should start recovering toward neutral
func shouldRecoverDirection(box *market.BoxData, currentDirection market.GridDirection) bool {
if box == nil || currentDirection == market.GridDirectionNeutral {
return false
}
price := box.CurrentPrice
// Check if price is back inside the short box
return price >= box.ShortLower && price <= box.ShortUpper
}

View File

@@ -120,3 +120,223 @@ func TestGetBreakoutAction(t *testing.T) {
})
}
}
// ============================================================================
// Grid Direction Tests
// ============================================================================
func TestGetBuySellRatio(t *testing.T) {
tests := []struct {
name string
direction market.GridDirection
biasRatio float64
wantBuy float64
wantSell float64
}{
{"neutral", market.GridDirectionNeutral, 0.7, 0.5, 0.5},
{"long", market.GridDirectionLong, 0.7, 1.0, 0.0},
{"short", market.GridDirectionShort, 0.7, 0.0, 1.0},
{"long_bias_default", market.GridDirectionLongBias, 0.7, 0.7, 0.3},
{"short_bias_default", market.GridDirectionShortBias, 0.7, 0.3, 0.7},
{"long_bias_custom", market.GridDirectionLongBias, 0.8, 0.8, 0.2},
{"short_bias_custom", market.GridDirectionShortBias, 0.8, 0.2, 0.8},
{"invalid_bias_uses_default", market.GridDirectionLongBias, 0, 0.7, 0.3},
{"negative_bias_uses_default", market.GridDirectionLongBias, -1, 0.7, 0.3},
}
const tolerance = 0.0001
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buy, sell := tt.direction.GetBuySellRatio(tt.biasRatio)
buyDiff := buy - tt.wantBuy
sellDiff := sell - tt.wantSell
if buyDiff < -tolerance || buyDiff > tolerance || sellDiff < -tolerance || sellDiff > tolerance {
t.Errorf("GetBuySellRatio(%v, %v) = (%v, %v), want (%v, %v)",
tt.direction, tt.biasRatio, buy, sell, tt.wantBuy, tt.wantSell)
}
})
}
}
func TestDetermineGridDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95,
}
tests := []struct {
name string
currentDirection market.GridDirection
breakoutLevel market.BreakoutLevel
direction string
expected market.GridDirection
}{
// Short box breakouts
{
name: "short_breakout_up_neutral",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutShort,
direction: "up",
expected: market.GridDirectionLongBias,
},
{
name: "short_breakout_down_neutral",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutShort,
direction: "down",
expected: market.GridDirectionShortBias,
},
// Mid box breakouts
{
name: "mid_breakout_up",
currentDirection: market.GridDirectionLongBias,
breakoutLevel: market.BreakoutMid,
direction: "up",
expected: market.GridDirectionLong,
},
{
name: "mid_breakout_down",
currentDirection: market.GridDirectionShortBias,
breakoutLevel: market.BreakoutMid,
direction: "down",
expected: market.GridDirectionShort,
},
// Long box breakout - maintains current (emergency handling)
{
name: "long_breakout_maintains",
currentDirection: market.GridDirectionLong,
breakoutLevel: market.BreakoutLong,
direction: "up",
expected: market.GridDirectionLong,
},
// No breakout - tests recovery logic
{
name: "no_breakout_neutral_stays",
currentDirection: market.GridDirectionNeutral,
breakoutLevel: market.BreakoutNone,
direction: "",
expected: market.GridDirectionNeutral,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineGridDirection(box, tt.currentDirection, tt.breakoutLevel, tt.direction)
if result != tt.expected {
t.Errorf("determineGridDirection() = %v, want %v", result, tt.expected)
}
})
}
}
func TestDetermineRecoveryDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95, // Inside short box
}
tests := []struct {
name string
price float64
currentDirection market.GridDirection
expected market.GridDirection
}{
// Inside short box - should recover
{"long_to_long_bias", 95, market.GridDirectionLong, market.GridDirectionLongBias},
{"long_bias_to_neutral", 95, market.GridDirectionLongBias, market.GridDirectionNeutral},
{"short_to_short_bias", 95, market.GridDirectionShort, market.GridDirectionShortBias},
{"short_bias_to_neutral", 95, market.GridDirectionShortBias, market.GridDirectionNeutral},
{"neutral_stays_neutral", 95, market.GridDirectionNeutral, market.GridDirectionNeutral},
// Outside short box - should maintain
{"long_outside_stays", 101, market.GridDirectionLong, market.GridDirectionLong},
{"short_outside_stays", 89, market.GridDirectionShort, market.GridDirectionShort},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := determineRecoveryDirection(tt.price, box, tt.currentDirection)
if result != tt.expected {
t.Errorf("determineRecoveryDirection(%v, %v) = %v, want %v",
tt.price, tt.currentDirection, result, tt.expected)
}
})
}
}
func TestGetBreakoutActionWithDirection(t *testing.T) {
tests := []struct {
name string
level market.BreakoutLevel
enableDirectionAdjust bool
expected BreakoutAction
}{
// Direction adjustment disabled - original behavior
{"short_disabled", market.BreakoutShort, false, BreakoutActionReducePosition},
{"mid_disabled", market.BreakoutMid, false, BreakoutActionPauseGrid},
{"long_disabled", market.BreakoutLong, false, BreakoutActionCloseAll},
// Direction adjustment enabled
{"short_enabled", market.BreakoutShort, true, BreakoutActionAdjustDirection},
{"mid_enabled", market.BreakoutMid, true, BreakoutActionAdjustDirection},
{"long_enabled", market.BreakoutLong, true, BreakoutActionCloseAll}, // Long always triggers emergency
{"none_enabled", market.BreakoutNone, true, BreakoutActionNone},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
action := getBreakoutActionWithDirection(tt.level, tt.enableDirectionAdjust)
if action != tt.expected {
t.Errorf("getBreakoutActionWithDirection(%v, %v) = %v, want %v",
tt.level, tt.enableDirectionAdjust, action, tt.expected)
}
})
}
}
func TestShouldRecoverDirection(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95,
}
tests := []struct {
name string
price float64
direction market.GridDirection
expected bool
}{
{"neutral_inside_no_recovery", 95, market.GridDirectionNeutral, false},
{"long_inside_should_recover", 95, market.GridDirectionLong, true},
{"long_outside_no_recovery", 101, market.GridDirectionLong, false},
{"short_inside_should_recover", 95, market.GridDirectionShort, true},
{"short_outside_no_recovery", 89, market.GridDirectionShort, false},
{"long_bias_inside_should_recover", 95, market.GridDirectionLongBias, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
box.CurrentPrice = tt.price
result := shouldRecoverDirection(box, tt.direction)
if result != tt.expected {
t.Errorf("shouldRecoverDirection(price=%v, %v) = %v, want %v",
tt.price, tt.direction, result, tt.expected)
}
})
}
}

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"os"

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"math"

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"bytes"
@@ -16,6 +16,7 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid"
"nofx/trader/types"
)
// HyperliquidTrader Hyperliquid trader
@@ -249,7 +250,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
//
// To be compatible with auto_trader.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
// To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
// Need to return "wallet balance without unrealized PnL"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
@@ -1950,14 +1951,14 @@ func absFloat(x float64) float64 {
// GetClosedPnL gets recent closing trades from Hyperliquid
// Note: Hyperliquid does NOT have a position history API, only fill history.
// This returns individual closing trades for real-time position closure detection.
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
@@ -1981,7 +1982,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
}
}
records = append(records, ClosedPnLRecord{
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -2001,7 +2002,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
}
// GetTrades retrieves trade history from Hyperliquid
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
// Use UserFillsByTime API
startTimeMs := startTime.UnixMilli()
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
@@ -2009,7 +2010,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
return nil, fmt.Errorf("failed to get user fills: %w", err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, fill := range fills {
price, _ := strconv.ParseFloat(fill.Price, 64)
qty, _ := strconv.ParseFloat(fill.Size, 64)
@@ -2054,7 +2055,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
}
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(fill.Tid, 10),
Symbol: fill.Coin,
Side: side,
@@ -2082,13 +2083,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
var defaultBuilder *hyperliquid.BuilderInfo = nil
// GetOpenOrders gets all open/pending orders for a symbol
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result []OpenOrder
var result []types.OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
@@ -2099,7 +2100,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
side = "SELL"
}
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
@@ -2117,7 +2118,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
coin := convertSymbolToHyperliquid(req.Symbol)
// Set leverage if specified and not xyz dex
@@ -2165,7 +2166,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrder
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
coin, req.Side, roundedPrice)
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"context"
@@ -11,7 +11,7 @@ import (
// TestMetaConcurrentAccess tests that concurrent access to meta field is safe
func TestMetaConcurrentAccess(t *testing.T) {
// Create a HyperliquidTrader instance with meta initialized
trader := &HyperliquidTrader{
ht := &HyperliquidTrader{
ctx: context.Background(),
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
@@ -32,7 +32,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
go func() {
defer wg.Done()
// This should not cause race conditions
decimals := trader.getSzDecimals("BTC")
decimals := ht.getSzDecimals("BTC")
if decimals != 5 {
t.Errorf("Expected decimals 5, got %d", decimals)
}
@@ -44,7 +44,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field
func TestMetaConcurrentReadWrite(t *testing.T) {
trader := &HyperliquidTrader{
ht := &HyperliquidTrader{
ctx: context.Background(),
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
@@ -62,7 +62,7 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
trader.getSzDecimals("BTC")
ht.getSzDecimals("BTC")
}()
}
@@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
go func(iteration int) {
defer wg.Done()
// Simulate meta update
trader.metaMutex.Lock()
trader.meta = &hyperliquid.Meta{
ht.metaMutex.Lock()
ht.meta = &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5 + iteration%3},
{Name: "ETH", SzDecimals: 4},
},
}
trader.metaMutex.Unlock()
ht.metaMutex.Unlock()
}(i)
}
wg.Wait()
// Verify meta is not nil after all operations
trader.metaMutex.RLock()
if trader.meta == nil {
ht.metaMutex.RLock()
if ht.meta == nil {
t.Error("Meta should not be nil after concurrent operations")
}
trader.metaMutex.RUnlock()
ht.metaMutex.RUnlock()
}
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
func TestGetSzDecimals_NilMeta(t *testing.T) {
trader := &HyperliquidTrader{
ht := &HyperliquidTrader{
meta: nil,
metaMutex: sync.RWMutex{},
}
// Should return default value 4 when meta is nil
decimals := trader.getSzDecimals("BTC")
decimals := ht.getSzDecimals("BTC")
expectedDecimals := 4
if decimals != expectedDecimals {
@@ -111,7 +111,7 @@ func TestGetSzDecimals_NilMeta(t *testing.T) {
// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta
func TestGetSzDecimals_ValidMeta(t *testing.T) {
trader := &HyperliquidTrader{
ht := &HyperliquidTrader{
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5},
@@ -133,7 +133,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
for _, tt := range tests {
t.Run(tt.coin, func(t *testing.T) {
decimals := trader.getSzDecimals(tt.coin)
decimals := ht.getSzDecimals(tt.coin)
if decimals != tt.expectedDecimals {
t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals)
}
@@ -144,7 +144,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues
// Run with: go test -race -run TestMetaMutex_NoRaceCondition
func TestMetaMutex_NoRaceCondition(t *testing.T) {
trader := &HyperliquidTrader{
ht := &HyperliquidTrader{
ctx: context.Background(),
meta: &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
@@ -163,8 +163,8 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
wg.Add(1)
go func() {
defer wg.Done()
trader.getSzDecimals("BTC")
trader.getSzDecimals("ETH")
ht.getSzDecimals("BTC")
ht.getSzDecimals("ETH")
}()
}
@@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
wg.Add(1)
go func(idx int) {
defer wg.Done()
trader.metaMutex.Lock()
trader.meta = &hyperliquid.Meta{
ht.metaMutex.Lock()
ht.meta = &hyperliquid.Meta{
Universe: []hyperliquid.AssetInfo{
{Name: "BTC", SzDecimals: 5},
{Name: "ETH", SzDecimals: 4},
{Name: "SOL", SzDecimals: 3},
},
}
trader.metaMutex.Unlock()
ht.metaMutex.Unlock()
}(i)
}

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"context"
@@ -11,6 +11,8 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -20,9 +22,9 @@ import (
// HyperliquidTestSuite Hyperliquid trader test suite
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
type HyperliquidTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
privateKey *ecdsa.PrivateKey
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
privateKey *ecdsa.PrivateKey
}
// NewHyperliquidTestSuite Create Hyperliquid test suite
@@ -216,7 +218,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
},
}
trader := &HyperliquidTrader{
traderInstance := &HyperliquidTrader{
exchange: exchange,
ctx: ctx,
walletAddr: walletAddr,
@@ -225,7 +227,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
}
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &HyperliquidTestSuite{
TraderTestSuite: baseSuite,
@@ -248,7 +250,7 @@ func (s *HyperliquidTestSuite) Cleanup() {
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*HyperliquidTrader)(nil)
var _ types.Trader = (*HyperliquidTrader)(nil)
}
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
@@ -281,7 +283,7 @@ func TestNewHyperliquidTrader(t *testing.T) {
walletAddr: "0x1234567890123456789012345678901234567890",
testnet: true,
wantError: true,
errorContains: "Failed to parse private key",
errorContains: "failed to parse private key",
},
{
name: "Empty wallet address",
@@ -562,8 +564,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader := &HyperliquidTrader{meta: tt.meta}
result := trader.getSzDecimals(tt.coin)
ht := &HyperliquidTrader{meta: tt.meta}
result := ht.getSzDecimals(tt.coin)
assert.Equal(t, tt.expected, result)
})
}

View File

@@ -1,4 +1,4 @@
package trader
package hyperliquid
import (
"bytes"

View File

@@ -3,161 +3,19 @@ package trader
import (
"fmt"
"nofx/logger"
"time"
"nofx/trader/types"
)
// ClosedPnLRecord represents a single closed position record from exchange
type ClosedPnLRecord struct {
Symbol string // Trading pair (e.g., "BTCUSDT")
Side string // "long" or "short"
EntryPrice float64 // Entry price
ExitPrice float64 // Exit/close price
Quantity float64 // Position size
RealizedPnL float64 // Realized profit/loss
Fee float64 // Trading fee/commission
Leverage int // Leverage used
EntryTime time.Time // Position open time
ExitTime time.Time // Position close time
OrderID string // Close order ID
CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown"
ExchangeID string // Exchange-specific position ID
}
// TradeRecord represents a single trade/fill from exchange
// Used for reconstructing position history with unified algorithm
type TradeRecord struct {
TradeID string // Unique trade ID from exchange
Symbol string // Trading pair (e.g., "BTCUSDT")
Side string // "BUY" or "SELL"
PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode)
OrderAction string // "open_long", "open_short", "close_long", "close_short" (from exchange Dir field)
Price float64 // Execution price
Quantity float64 // Executed quantity
RealizedPnL float64 // Realized PnL (non-zero for closing trades)
Fee float64 // Trading fee/commission
Time time.Time // Trade execution time
}
// Trader Unified trader interface
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
type Trader interface {
// GetBalance Get account balance
GetBalance() (map[string]interface{}, error)
// GetPositions Get all positions
GetPositions() ([]map[string]interface{}, error)
// OpenLong Open long position
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
// OpenShort Open short position
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
// CloseLong Close long position (quantity=0 means close all)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
// CloseShort Close short position (quantity=0 means close all)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
// SetLeverage Set leverage
SetLeverage(symbol string, leverage int) error
// SetMarginMode Set position mode (true=cross margin, false=isolated margin)
SetMarginMode(symbol string, isCrossMargin bool) error
// GetMarketPrice Get market price
GetMarketPrice(symbol string) (float64, error)
// SetStopLoss Set stop-loss order
SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error
// SetTakeProfit Set take-profit order
SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error
// CancelStopLossOrders Cancel only stop-loss orders (BUG fix: don't delete take-profit when adjusting stop-loss)
CancelStopLossOrders(symbol string) error
// CancelTakeProfitOrders Cancel only take-profit orders (BUG fix: don't delete stop-loss when adjusting take-profit)
CancelTakeProfitOrders(symbol string) error
// CancelAllOrders Cancel all pending orders for this symbol
CancelAllOrders(symbol string) error
// CancelStopOrders Cancel stop-loss/take-profit orders for this symbol (for adjusting stop-loss/take-profit positions)
CancelStopOrders(symbol string) error
// FormatQuantity Format quantity to correct precision
FormatQuantity(symbol string, quantity float64) (string, error)
// GetOrderStatus Get order status
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error)
// GetClosedPnL Get closed position PnL records from exchange
// startTime: start time for query (usually last sync time)
// limit: max number of records to return
// Returns accurate exit price, fees, and close reason for positions closed externally
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
// GetOpenOrders Get open/pending orders from exchange
// Returns stop-loss, take-profit, and limit orders that haven't been filled
GetOpenOrders(symbol string) ([]OpenOrder, error)
}
// OpenOrder represents a pending order on the exchange
type OpenOrder struct {
OrderID string `json:"order_id"`
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
Price float64 `json:"price"` // Order price (for limit orders)
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW
}
// LimitOrderRequest represents a limit order request for grid trading
type LimitOrderRequest struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
Price float64 `json:"price"` // Limit price
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
PostOnly bool `json:"post_only"` // Maker only order
ReduceOnly bool `json:"reduce_only"` // Reduce position only
ClientID string `json:"client_id"` // Client order ID for tracking
}
// LimitOrderResult represents the result of placing a limit order
type LimitOrderResult struct {
OrderID string `json:"order_id"`
ClientID string `json:"client_id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PositionSide string `json:"position_side"`
Price float64 `json:"price"`
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
}
// GridTrader extends Trader interface with limit order support for grid trading
// Exchanges that support grid trading should implement this interface
type GridTrader interface {
Trader
// PlaceLimitOrder places a limit order at specified price
// Returns order ID and status
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
// CancelOrder cancels a specific order by ID
CancelOrder(symbol, orderID string) error
// GetOrderBook gets current order book (for price validation)
// Returns best bid/ask prices
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
}
// Re-export types for backward compatibility
type (
ClosedPnLRecord = types.ClosedPnLRecord
TradeRecord = types.TradeRecord
Trader = types.Trader
OpenOrder = types.OpenOrder
LimitOrderRequest = types.LimitOrderRequest
LimitOrderResult = types.LimitOrderResult
GridTrader = types.GridTrader
)
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
// Uses stop orders as a fallback when limit orders aren't directly available

412
trader/kucoin/order_sync.go Normal file
View File

@@ -0,0 +1,412 @@
package kucoin
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/store"
"nofx/trader/types"
"sort"
"strings"
"time"
)
// KuCoinTrade represents a trade record from KuCoin fill history
type KuCoinTrade struct {
Symbol string
TradeID string
OrderID string
Side string // buy or sell
FillPrice float64
FillQty float64 // In base currency (e.g., ETH), not lots
Fee float64
FeeAsset string
ExecTime time.Time
ProfitLoss float64
OrderAction string // open_long, open_short, close_long, close_short
}
// GetTrades retrieves trade/fill records from KuCoin
func (t *KuCoinTrader) GetTrades(startTime time.Time, limit int) ([]KuCoinTrade, error) {
if limit <= 0 {
limit = 100
}
if limit > 100 {
limit = 100 // KuCoin max limit
}
// Build query path
path := fmt.Sprintf("%s?pageSize=%d", kucoinFillsPath, limit)
if !startTime.IsZero() {
path += fmt.Sprintf("&startAt=%d", startTime.UnixMilli())
}
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var response struct {
CurrentPage int `json:"currentPage"`
PageSize int `json:"pageSize"`
TotalNum int `json:"totalNum"`
TotalPage int `json:"totalPage"`
Items []struct {
Symbol string `json:"symbol"`
TradeId string `json:"tradeId"`
OrderId string `json:"orderId"`
Side string `json:"side"`
Price string `json:"price"`
Size int64 `json:"size"`
Value string `json:"value"` // Trade value in quote currency
Fee string `json:"fee"` // Total fee
FeeRate string `json:"feeRate"` // Fee rate
FeeCurrency string `json:"feeCurrency"` // Fee currency (USDT)
OpenFeePay string `json:"openFeePay"` // Fee for opening (>0 means opening trade)
CloseFeePay string `json:"closeFeePay"` // Fee for closing (>0 means closing trade)
TradeTime int64 `json:"tradeTime"` // Nanoseconds
MarginMode string `json:"marginMode"` // CROSS or ISOLATED
OrderType string `json:"orderType"` // market, limit
} `json:"items"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, fmt.Errorf("failed to parse trade history: %w", err)
}
logger.Infof("📥 Received %d trades from KuCoin", len(response.Items))
result := make([]KuCoinTrade, 0, len(response.Items))
for _, trade := range response.Items {
// Parse numeric values from strings
var fillPrice, fee, openFeePay, closeFeePay float64
fmt.Sscanf(trade.Price, "%f", &fillPrice)
fmt.Sscanf(trade.Fee, "%f", &fee)
fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay)
fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay)
// Get multiplier from contract info
symbol := t.convertSymbolBack(trade.Symbol)
var multiplier float64
contract, err := t.getContract(symbol)
if err == nil && contract != nil {
multiplier = contract.Multiplier
} else {
// Default multipliers based on symbol
if strings.Contains(symbol, "BTC") {
multiplier = 0.001
} else {
multiplier = 0.01 // Default for altcoins
}
}
// Convert lots to actual quantity
absSize := trade.Size
if absSize < 0 {
absSize = -absSize
}
fillQty := float64(absSize) * multiplier
// Determine side and order action
// KuCoin uses openFeePay/closeFeePay to indicate if trade is opening or closing
side := strings.ToUpper(trade.Side) // BUY or SELL
isClosing := closeFeePay > 0
var orderAction string
if trade.Side == "buy" {
if isClosing {
// Buying to close short
orderAction = "close_short"
} else {
// Buying to open long
orderAction = "open_long"
}
} else { // sell
if isClosing {
// Selling to close long
orderAction = "close_long"
} else {
// Selling to open short
orderAction = "open_short"
}
}
// Trade time is in nanoseconds
execTime := time.Unix(0, trade.TradeTime)
result = append(result, KuCoinTrade{
Symbol: symbol,
TradeID: trade.TradeId,
OrderID: trade.OrderId,
Side: side,
FillPrice: fillPrice,
FillQty: fillQty,
Fee: fee,
FeeAsset: trade.FeeCurrency,
ExecTime: execTime,
ProfitLoss: 0, // KuCoin fills API doesn't return PnL per trade
OrderAction: orderAction,
})
}
// Sort by execution time (oldest first)
sort.Slice(result, func(i, j int) bool {
return result[i].ExecTime.Before(result[j].ExecTime)
})
return result, nil
}
// GetRecentTrades retrieves recent trades (faster, no pagination)
func (t *KuCoinTrader) GetRecentTrades() ([]KuCoinTrade, error) {
data, err := t.doRequest("GET", kucoinRecentFillsPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get recent trades: %w", err)
}
var trades []struct {
Symbol string `json:"symbol"`
TradeId string `json:"tradeId"`
OrderId string `json:"orderId"`
Side string `json:"side"`
Price string `json:"price"`
Size int64 `json:"size"`
Fee string `json:"fee"`
FeeCurrency string `json:"feeCurrency"`
OpenFeePay string `json:"openFeePay"`
CloseFeePay string `json:"closeFeePay"`
TradeTime int64 `json:"tradeTime"`
}
if err := json.Unmarshal(data, &trades); err != nil {
return nil, fmt.Errorf("failed to parse recent trades: %w", err)
}
result := make([]KuCoinTrade, 0, len(trades))
for _, trade := range trades {
var fillPrice, fee, openFeePay, closeFeePay float64
fmt.Sscanf(trade.Price, "%f", &fillPrice)
fmt.Sscanf(trade.Fee, "%f", &fee)
fmt.Sscanf(trade.OpenFeePay, "%f", &openFeePay)
fmt.Sscanf(trade.CloseFeePay, "%f", &closeFeePay)
// Get multiplier from contract info
symbol := t.convertSymbolBack(trade.Symbol)
var multiplier float64
contract, err := t.getContract(symbol)
if err == nil && contract != nil {
multiplier = contract.Multiplier
} else {
if strings.Contains(symbol, "BTC") {
multiplier = 0.001
} else {
multiplier = 0.01
}
}
absSize := trade.Size
if absSize < 0 {
absSize = -absSize
}
fillQty := float64(absSize) * multiplier
side := strings.ToUpper(trade.Side)
isClosing := closeFeePay > 0
var orderAction string
if trade.Side == "buy" {
if isClosing {
orderAction = "close_short"
} else {
orderAction = "open_long"
}
} else {
if isClosing {
orderAction = "close_long"
} else {
orderAction = "open_short"
}
}
execTime := time.Unix(0, trade.TradeTime)
result = append(result, KuCoinTrade{
Symbol: symbol,
TradeID: trade.TradeId,
OrderID: trade.OrderId,
Side: side,
FillPrice: fillPrice,
FillQty: fillQty,
Fee: fee,
FeeAsset: trade.FeeCurrency,
ExecTime: execTime,
ProfitLoss: 0,
OrderAction: orderAction,
})
}
return result, nil
}
// ToTradeRecord converts KuCoinTrade to types.TradeRecord
func (t *KuCoinTrade) ToTradeRecord() types.TradeRecord {
// Determine position side from order action
positionSide := "LONG"
if strings.Contains(t.OrderAction, "short") {
positionSide = "SHORT"
}
return types.TradeRecord{
TradeID: t.TradeID,
Symbol: t.Symbol,
Side: t.Side,
PositionSide: positionSide,
OrderAction: t.OrderAction,
Price: t.FillPrice,
Quantity: t.FillQty,
RealizedPnL: t.ProfitLoss,
Fee: t.Fee,
Time: t.ExecTime,
}
}
// SyncOrdersFromKuCoin syncs KuCoin exchange order history to local database
// Also creates/updates position records to ensure orders/fills/positions data consistency
// exchangeID: Exchange account UUID (from exchanges.id)
// exchangeType: Exchange type ("kucoin")
func (t *KuCoinTrader) SyncOrdersFromKuCoin(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
if st == nil {
return fmt.Errorf("store is nil")
}
// Get recent trades (last 24 hours)
startTime := time.Now().Add(-24 * time.Hour)
logger.Infof("🔄 Syncing KuCoin trades from: %s", startTime.Format(time.RFC3339))
// Use GetTrades method to fetch trade records
trades, err := t.GetTrades(startTime, 100)
if err != nil {
return fmt.Errorf("failed to get trades: %w", err)
}
logger.Infof("📥 Received %d trades from KuCoin", len(trades))
// Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
})
// Process trades one by one (no transaction to avoid deadlock)
orderStore := st.Order()
positionStore := st.Position()
posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0
for _, trade := range trades {
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
continue // Order already exists, skip
}
// Symbol is already normalized in GetTrades
symbol := trade.Symbol
// Determine position side from order action
positionSide := "LONG"
if strings.Contains(trade.OrderAction, "short") {
positionSide = "SHORT"
}
// Normalize side for storage
side := strings.ToUpper(trade.Side)
// Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: trade.TradeID,
Symbol: symbol,
Side: side,
PositionSide: "BOTH", // KuCoin uses one-way position mode
Type: "MARKET",
OrderAction: trade.OrderAction,
Quantity: trade.FillQty,
Price: trade.FillPrice,
Status: "FILLED",
FilledQuantity: trade.FillQty,
AvgFillPrice: trade.FillPrice,
Commission: trade.Fee,
FilledAt: execTimeMs,
CreatedAt: execTimeMs,
UpdatedAt: execTimeMs,
}
// Insert order record
if err := orderStore.CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
continue
}
// Create fill record - use UTC time in milliseconds
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type
OrderID: orderRecord.ID,
ExchangeOrderID: trade.OrderID,
ExchangeTradeID: trade.TradeID,
Symbol: symbol,
Side: side,
Price: trade.FillPrice,
Quantity: trade.FillQty,
QuoteQuantity: trade.FillPrice * trade.FillQty,
Commission: trade.Fee,
CommissionAsset: trade.FeeAsset,
RealizedPnL: trade.ProfitLoss,
IsMaker: false,
CreatedAt: execTimeMs,
}
if err := orderStore.CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
}
// Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
execTimeMs, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty)
}
syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
}
logger.Infof("✅ KuCoin order sync completed: %d new trades synced", syncedCount)
return nil
}
// StartOrderSync starts background order sync task for KuCoin
func (t *KuCoinTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
if err := t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ KuCoin order sync failed: %v", err)
}
}
}()
logger.Infof("🔄 KuCoin order sync started (interval: %v)", interval)
}

View File

@@ -0,0 +1,628 @@
package kucoin
import (
"encoding/json"
"fmt"
"os"
"testing"
"time"
)
// Test credentials - set via environment variables
func getKuCoinTestCredentials(t *testing.T) (string, string, string) {
apiKey := os.Getenv("KUCOIN_TEST_API_KEY")
secretKey := os.Getenv("KUCOIN_TEST_SECRET_KEY")
passphrase := os.Getenv("KUCOIN_TEST_PASSPHRASE")
if apiKey == "" || secretKey == "" || passphrase == "" {
t.Skip("KuCoin test credentials not set (KUCOIN_TEST_API_KEY, KUCOIN_TEST_SECRET_KEY, KUCOIN_TEST_PASSPHRASE)")
}
return apiKey, secretKey, passphrase
}
func createKuCoinTestTrader(t *testing.T) *KuCoinTrader {
apiKey, secretKey, passphrase := getKuCoinTestCredentials(t)
trader := NewKuCoinTrader(apiKey, secretKey, passphrase)
return trader
}
// TestKuCoinConnection tests basic API connectivity
func TestKuCoinConnection(t *testing.T) {
trader := createKuCoinTestTrader(t)
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("Failed to get balance: %v", err)
}
t.Logf("✅ Connection OK")
t.Logf(" totalWalletBalance: %v", balance["totalWalletBalance"])
t.Logf(" availableBalance: %v", balance["availableBalance"])
t.Logf(" totalUnrealizedProfit: %v", balance["totalUnrealizedProfit"])
t.Logf(" totalEquity: %v", balance["totalEquity"])
}
// TestKuCoinGetPositions tests position retrieval
func TestKuCoinGetPositions(t *testing.T) {
trader := createKuCoinTestTrader(t)
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
t.Logf("📊 Found %d positions:", len(positions))
for i, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
posAmt := pos["positionAmt"].(float64)
entryPrice := pos["entryPrice"].(float64)
markPrice := pos["markPrice"].(float64)
unrealizedPnl := pos["unRealizedProfit"].(float64)
leverage := pos["leverage"].(float64)
mgnMode := pos["mgnMode"].(string)
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f mark=%.4f pnl=%.4f lev=%.0f mode=%s",
i+1, symbol, side, posAmt, entryPrice, markPrice, unrealizedPnl, leverage, mgnMode)
}
}
// TestKuCoinGetTrades tests trade history retrieval with proper JSON parsing
func TestKuCoinGetTrades(t *testing.T) {
trader := createKuCoinTestTrader(t)
// Get trades from last 24 hours (KuCoin API quirk: >24h startAt returns 0)
startTime := time.Now().Add(-24 * time.Hour)
trades, err := trader.GetTrades(startTime, 100)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
t.Logf("📋 Retrieved %d trades from KuCoin:", len(trades))
for i, trade := range trades {
t.Logf(" [%d] %s | TradeID: %s | OrderID: %s", i+1, trade.ExecTime.Format("2006-01-02 15:04:05"), trade.TradeID, trade.OrderID)
t.Logf(" Symbol: %s | Side: %s | Action: %s", trade.Symbol, trade.Side, trade.OrderAction)
t.Logf(" Price: %.4f | Qty: %.6f | Fee: %.6f %s", trade.FillPrice, trade.FillQty, trade.Fee, trade.FeeAsset)
t.Logf(" PnL: %.4f", trade.ProfitLoss)
}
// Verify trade data integrity
for i, trade := range trades {
if trade.TradeID == "" {
t.Errorf("Trade %d has empty TradeID", i)
}
if trade.Symbol == "" {
t.Errorf("Trade %d has empty Symbol", i)
}
if trade.Side != "BUY" && trade.Side != "SELL" {
t.Errorf("Trade %d has invalid Side: %s (expected BUY or SELL)", i, trade.Side)
}
if trade.OrderAction != "open_long" && trade.OrderAction != "open_short" &&
trade.OrderAction != "close_long" && trade.OrderAction != "close_short" {
t.Errorf("Trade %d has invalid OrderAction: %s", i, trade.OrderAction)
}
if trade.FillPrice <= 0 {
t.Errorf("Trade %d has invalid FillPrice: %.6f", i, trade.FillPrice)
}
if trade.FillQty <= 0 {
t.Errorf("Trade %d has invalid FillQty: %.6f", i, trade.FillQty)
}
}
}
// TestKuCoinGetRecentTrades tests recent trades endpoint
func TestKuCoinGetRecentTrades(t *testing.T) {
trader := createKuCoinTestTrader(t)
trades, err := trader.GetRecentTrades()
if err != nil {
t.Fatalf("Failed to get recent trades: %v", err)
}
t.Logf("📋 Retrieved %d recent trades from KuCoin:", len(trades))
for i, trade := range trades {
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f action=%s",
i+1, trade.ExecTime.Format("01-02 15:04:05"), trade.Symbol, trade.Side,
trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.OrderAction)
}
}
// TestKuCoinTradeToRecord tests conversion to TradeRecord
func TestKuCoinTradeToRecord(t *testing.T) {
// Test open_long
trade1 := KuCoinTrade{
TradeID: "test-trade-1",
Symbol: "BTCUSDT",
Side: "BUY",
OrderAction: "open_long",
FillPrice: 50000.0,
FillQty: 0.01,
Fee: 0.5,
ProfitLoss: 0,
}
record1 := trade1.ToTradeRecord()
if record1.PositionSide != "LONG" {
t.Errorf("open_long should have PositionSide=LONG, got %s", record1.PositionSide)
}
// Test close_long
trade2 := KuCoinTrade{
TradeID: "test-trade-2",
Symbol: "BTCUSDT",
Side: "SELL",
OrderAction: "close_long",
FillPrice: 51000.0,
FillQty: 0.01,
Fee: 0.5,
ProfitLoss: 10.0,
}
record2 := trade2.ToTradeRecord()
if record2.PositionSide != "LONG" {
t.Errorf("close_long should have PositionSide=LONG, got %s", record2.PositionSide)
}
// Test open_short
trade3 := KuCoinTrade{
TradeID: "test-trade-3",
Symbol: "ETHUSDT",
Side: "SELL",
OrderAction: "open_short",
FillPrice: 3000.0,
FillQty: 0.1,
Fee: 0.3,
ProfitLoss: 0,
}
record3 := trade3.ToTradeRecord()
if record3.PositionSide != "SHORT" {
t.Errorf("open_short should have PositionSide=SHORT, got %s", record3.PositionSide)
}
// Test close_short
trade4 := KuCoinTrade{
TradeID: "test-trade-4",
Symbol: "ETHUSDT",
Side: "BUY",
OrderAction: "close_short",
FillPrice: 2900.0,
FillQty: 0.1,
Fee: 0.3,
ProfitLoss: 10.0,
}
record4 := trade4.ToTradeRecord()
if record4.PositionSide != "SHORT" {
t.Errorf("close_short should have PositionSide=SHORT, got %s", record4.PositionSide)
}
t.Logf("✅ TradeRecord conversion tests passed")
}
// TestKuCoinOrderActionDetermination tests that order action is correctly determined
func TestKuCoinOrderActionDetermination(t *testing.T) {
trader := createKuCoinTestTrader(t)
startTime := time.Now().Add(-24 * time.Hour)
trades, err := trader.GetTrades(startTime, 100)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
// Analyze trade patterns
actionCounts := make(map[string]int)
for _, trade := range trades {
actionCounts[trade.OrderAction]++
}
t.Logf("📊 Order action distribution:")
for action, count := range actionCounts {
t.Logf(" %s: %d", action, count)
}
// Verify logical consistency:
// - BUY + open_long: opening a long position
// - BUY + close_short: closing a short position
// - SELL + open_short: opening a short position
// - SELL + close_long: closing a long position
for i, trade := range trades {
switch trade.OrderAction {
case "open_long":
if trade.Side != "BUY" {
t.Errorf("Trade %d: open_long should have Side=BUY, got %s", i, trade.Side)
}
case "close_short":
if trade.Side != "BUY" {
t.Errorf("Trade %d: close_short should have Side=BUY, got %s", i, trade.Side)
}
case "open_short":
if trade.Side != "SELL" {
t.Errorf("Trade %d: open_short should have Side=SELL, got %s", i, trade.Side)
}
case "close_long":
if trade.Side != "SELL" {
t.Errorf("Trade %d: close_long should have Side=SELL, got %s", i, trade.Side)
}
}
}
}
// TestKuCoinPositionBuilding tests that trades can be used to build position state
func TestKuCoinPositionBuilding(t *testing.T) {
trader := createKuCoinTestTrader(t)
startTime := time.Now().Add(-24 * time.Hour)
trades, err := trader.GetTrades(startTime, 100)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
// Group trades by symbol and build position state
type PositionState struct {
LongQty float64
ShortQty float64
LongPnL float64
ShortPnL float64
TradeCount int
}
positions := make(map[string]*PositionState)
for _, trade := range trades {
if positions[trade.Symbol] == nil {
positions[trade.Symbol] = &PositionState{}
}
pos := positions[trade.Symbol]
pos.TradeCount++
switch trade.OrderAction {
case "open_long":
pos.LongQty += trade.FillQty
case "close_long":
pos.LongQty -= trade.FillQty
pos.LongPnL += trade.ProfitLoss
case "open_short":
pos.ShortQty += trade.FillQty
case "close_short":
pos.ShortQty -= trade.FillQty
pos.ShortPnL += trade.ProfitLoss
}
}
t.Logf("📊 Calculated position states from %d trades:", len(trades))
for symbol, pos := range positions {
t.Logf(" %s: trades=%d longQty=%.6f shortQty=%.6f longPnL=%.4f shortPnL=%.4f",
symbol, pos.TradeCount, pos.LongQty, pos.ShortQty, pos.LongPnL, pos.ShortPnL)
}
// Now compare with actual positions from exchange
actualPositions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Failed to get actual positions: %v", err)
}
t.Logf("\n📊 Actual positions from exchange:")
for _, pos := range actualPositions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
qty := pos["positionAmt"].(float64)
t.Logf(" %s %s: qty=%.6f", symbol, side, qty)
}
}
// TestKuCoinRawAPIResponse tests raw API response to verify field types
func TestKuCoinRawAPIResponse(t *testing.T) {
trader := createKuCoinTestTrader(t)
// Make raw request to fills endpoint
startTime := time.Now().Add(-24 * time.Hour)
path := fmt.Sprintf("%s?pageSize=10&startAt=%d", kucoinFillsPath, startTime.UnixMilli())
data, err := trader.doRequest("GET", path, nil)
if err != nil {
t.Fatalf("Failed to get raw fills data: %v", err)
}
t.Logf("📋 Raw API response (first 2000 chars):")
response := string(data)
if len(response) > 2000 {
response = response[:2000] + "..."
}
t.Logf("%s", response)
}
// TestKuCoinValueCalculation tests that calculated value (price * qty) matches API value
// This is the key test to verify multiplier and qty calculation is correct
func TestKuCoinValueCalculation(t *testing.T) {
trader := createKuCoinTestTrader(t)
// Get raw API response to compare
path := fmt.Sprintf("%s?pageSize=20", kucoinFillsPath)
data, err := trader.doRequest("GET", path, nil)
if err != nil {
t.Fatalf("Failed to get raw fills: %v", err)
}
var rawResponse struct {
Items []struct {
Symbol string `json:"symbol"`
TradeId string `json:"tradeId"`
Price string `json:"price"`
Size int64 `json:"size"`
Value string `json:"value"` // This is the actual USDT value from API
Side string `json:"side"`
} `json:"items"`
}
if err := json.Unmarshal(data, &rawResponse); err != nil {
t.Fatalf("Failed to parse raw response: %v", err)
}
// Get trades via GetTrades
trades, err := trader.GetTrades(time.Time{}, 20)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
// Build a map of tradeID -> calculated value
calculatedValues := make(map[string]float64)
for _, trade := range trades {
calculatedValues[trade.TradeID] = trade.FillPrice * trade.FillQty
}
t.Logf("Comparing API value vs calculated value (price * qty):")
t.Logf("==========================================")
errorCount := 0
for i, raw := range rawResponse.Items {
if i >= 10 {
break
}
var apiValue float64
fmt.Sscanf(raw.Value, "%f", &apiValue)
calculatedValue, exists := calculatedValues[raw.TradeId]
if !exists {
t.Errorf("Trade %s not found in GetTrades result", raw.TradeId)
continue
}
// Allow 1% tolerance for rounding
tolerance := apiValue * 0.01
diff := calculatedValue - apiValue
if diff < 0 {
diff = -diff
}
status := "✅"
if diff > tolerance {
status = "❌"
errorCount++
}
t.Logf(" %s [%d] %s: API value=%.4f, Calculated=%.4f, Diff=%.4f",
status, i+1, raw.Symbol, apiValue, calculatedValue, diff)
}
if errorCount > 0 {
t.Errorf("Found %d trades with incorrect value calculation", errorCount)
}
}
// TestKuCoinEntryExitPrice tests that entry/exit prices are correctly captured
func TestKuCoinEntryExitPrice(t *testing.T) {
trader := createKuCoinTestTrader(t)
trades, err := trader.GetTrades(time.Time{}, 50)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
// Group trades by symbol to track entry/exit
type PositionTracker struct {
OpenTrades []KuCoinTrade
CloseTrades []KuCoinTrade
}
positions := make(map[string]*PositionTracker)
for _, trade := range trades {
if positions[trade.Symbol] == nil {
positions[trade.Symbol] = &PositionTracker{}
}
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
positions[trade.Symbol].OpenTrades = append(positions[trade.Symbol].OpenTrades, trade)
} else {
positions[trade.Symbol].CloseTrades = append(positions[trade.Symbol].CloseTrades, trade)
}
}
t.Logf("Entry/Exit price analysis:")
t.Logf("==========================")
for symbol, pos := range positions {
if len(pos.OpenTrades) == 0 && len(pos.CloseTrades) == 0 {
continue
}
// Calculate weighted average entry price
var totalEntryValue, totalEntryQty float64
for _, trade := range pos.OpenTrades {
totalEntryValue += trade.FillPrice * trade.FillQty
totalEntryQty += trade.FillQty
}
avgEntryPrice := 0.0
if totalEntryQty > 0 {
avgEntryPrice = totalEntryValue / totalEntryQty
}
// Calculate weighted average exit price
var totalExitValue, totalExitQty float64
for _, trade := range pos.CloseTrades {
totalExitValue += trade.FillPrice * trade.FillQty
totalExitQty += trade.FillQty
}
avgExitPrice := 0.0
if totalExitQty > 0 {
avgExitPrice = totalExitValue / totalExitQty
}
// Calculate P&L (simplified: (exit - entry) * qty for long)
pnl := 0.0
if totalEntryQty > 0 && totalExitQty > 0 {
// Use the smaller qty for P&L calculation
closedQty := totalExitQty
if totalEntryQty < closedQty {
closedQty = totalEntryQty
}
pnl = (avgExitPrice - avgEntryPrice) * closedQty
}
t.Logf(" %s:", symbol)
t.Logf(" Entry: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT",
len(pos.OpenTrades), totalEntryQty, avgEntryPrice, totalEntryValue)
t.Logf(" Exit: %d trades, total qty=%.6f, avg price=%.6f, value=%.2f USDT",
len(pos.CloseTrades), totalExitQty, avgExitPrice, totalExitValue)
t.Logf(" Calculated P&L: %.4f USDT", pnl)
// Verify entry qty matches exit qty for closed positions
if len(pos.OpenTrades) > 0 && len(pos.CloseTrades) > 0 {
qtyDiff := totalEntryQty - totalExitQty
if qtyDiff < 0 {
qtyDiff = -qtyDiff
}
tolerance := totalEntryQty * 0.001 // 0.1% tolerance
if qtyDiff > tolerance {
t.Logf(" ⚠️ Entry/Exit qty mismatch: %.6f", qtyDiff)
}
}
}
}
// TestKuCoinPnLCalculation tests P&L calculation against actual exchange data
func TestKuCoinPnLCalculation(t *testing.T) {
trader := createKuCoinTestTrader(t)
// Get current balance for reference
balance, err := trader.GetBalance()
if err != nil {
t.Logf("Warning: Could not get balance: %v", err)
} else {
t.Logf("Current account balance:")
t.Logf(" Total equity: %v", balance["totalEquity"])
t.Logf(" Available: %v", balance["availableBalance"])
}
trades, err := trader.GetTrades(time.Time{}, 50)
if err != nil {
t.Fatalf("Failed to get trades: %v", err)
}
// Group by symbol and calculate P&L
type SymbolPnL struct {
Symbol string
TotalFees float64
GrossPnL float64 // From price difference
NetPnL float64 // Gross - fees
OpenQty float64
CloseQty float64
AvgOpenPrice float64
AvgClosePrice float64
}
pnlBySymbol := make(map[string]*SymbolPnL)
for _, trade := range trades {
if pnlBySymbol[trade.Symbol] == nil {
pnlBySymbol[trade.Symbol] = &SymbolPnL{Symbol: trade.Symbol}
}
p := pnlBySymbol[trade.Symbol]
p.TotalFees += trade.Fee
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
p.OpenQty += trade.FillQty
p.AvgOpenPrice = (p.AvgOpenPrice*(p.OpenQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.OpenQty
} else {
p.CloseQty += trade.FillQty
p.AvgClosePrice = (p.AvgClosePrice*(p.CloseQty-trade.FillQty) + trade.FillPrice*trade.FillQty) / p.CloseQty
}
}
t.Logf("\nP&L Summary by Symbol:")
t.Logf("======================")
var totalGrossPnL, totalFees, totalNetPnL float64
for symbol, p := range pnlBySymbol {
closedQty := p.CloseQty
if p.OpenQty < closedQty {
closedQty = p.OpenQty
}
// For LONG: P&L = (exitPrice - entryPrice) * qty
if closedQty > 0 && p.AvgOpenPrice > 0 && p.AvgClosePrice > 0 {
p.GrossPnL = (p.AvgClosePrice - p.AvgOpenPrice) * closedQty
p.NetPnL = p.GrossPnL - p.TotalFees
}
totalGrossPnL += p.GrossPnL
totalFees += p.TotalFees
totalNetPnL += p.NetPnL
t.Logf(" %s:", symbol)
t.Logf(" Open: qty=%.6f @ avg price=%.6f", p.OpenQty, p.AvgOpenPrice)
t.Logf(" Close: qty=%.6f @ avg price=%.6f", p.CloseQty, p.AvgClosePrice)
t.Logf(" Fees: %.4f USDT", p.TotalFees)
t.Logf(" Gross P&L: %.4f USDT", p.GrossPnL)
t.Logf(" Net P&L: %.4f USDT", p.NetPnL)
}
t.Logf("\nTotal Summary:")
t.Logf(" Total Gross P&L: %.4f USDT", totalGrossPnL)
t.Logf(" Total Fees: %.4f USDT", totalFees)
t.Logf(" Total Net P&L: %.4f USDT", totalNetPnL)
}
// TestKuCoinGetTradesDebug tests GetTrades with detailed debugging
func TestKuCoinGetTradesDebug(t *testing.T) {
trader := createKuCoinTestTrader(t)
// Test with different time windows
timeWindows := []struct {
name string
duration time.Duration
}{
{"1 hour", 1 * time.Hour},
{"24 hours", 24 * time.Hour},
{"7 days", 7 * 24 * time.Hour},
{"no filter", 0},
}
for _, tw := range timeWindows {
var startTime time.Time
var path string
if tw.duration > 0 {
startTime = time.Now().Add(-tw.duration)
path = fmt.Sprintf("%s?pageSize=100&startAt=%d", kucoinFillsPath, startTime.UnixMilli())
} else {
path = fmt.Sprintf("%s?pageSize=100", kucoinFillsPath)
}
data, err := trader.doRequest("GET", path, nil)
if err != nil {
t.Errorf("Failed to get fills for %s: %v", tw.name, err)
continue
}
// Parse to count items
var resp struct {
TotalNum int `json:"totalNum"`
Items []struct {
TradeTime int64 `json:"tradeTime"`
} `json:"items"`
}
json.Unmarshal(data, &resp)
t.Logf("📋 %s: totalNum=%d, items=%d", tw.name, resp.TotalNum, len(resp.Items))
if len(resp.Items) > 0 {
firstTime := time.Unix(0, resp.Items[0].TradeTime)
t.Logf(" First trade time: %s", firstTime.Format(time.RFC3339))
}
}
}

1293
trader/kucoin/trader.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"encoding/json"
@@ -91,7 +91,7 @@ func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
// Calculate wallet balance (total equity - unrealized PnL)
walletBalance := balance.TotalEquity - balance.UnrealizedPnL
// Return in standard format compatible with auto_trader.go
// Return in standard format compatible with auto_types.go
// (totalEquity = totalWalletBalance + totalUnrealizedProfit)
return map[string]interface{}{
"totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL)
@@ -165,7 +165,7 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
result := make([]map[string]interface{}, 0, len(positions))
for _, pos := range positions {
// Return in standard format compatible with auto_trader.go
// Return in standard format compatible with auto_types.go
result = append(result, map[string]interface{}{
"symbol": pos.Symbol,
"side": pos.Side,

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"fmt"
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
"time"
tradertypes "nofx/trader/types"
)
// Test configuration - uses environment variables for security
@@ -684,7 +686,7 @@ func TestLighterPlaceLimitOrder(t *testing.T) {
limitPrice := marketPrice * 0.75
quantity := 0.01
req := &LimitOrderRequest{
req := &tradertypes.LimitOrderRequest{
Symbol: "ETH",
Side: "BUY",
PositionSide: "LONG",

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"context"
@@ -16,6 +16,7 @@ import (
lighterClient "github.com/elliottech/lighter-go/client"
lighterHTTP "github.com/elliottech/lighter-go/client/http"
"github.com/ethereum/go-ethereum/common/hexutil"
tradertypes "nofx/trader/types"
)
// AccountInfo LIGHTER account information
@@ -398,14 +399,14 @@ func (t *LighterTraderV2) Cleanup() error {
// GetClosedPnL gets closed position PnL records from exchange
// LIGHTER does not have a direct closed PnL API, returns empty slice
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]tradertypes.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord
var records []tradertypes.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
@@ -427,7 +428,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]Closed
}
}
records = append(records, ClosedPnLRecord{
records = append(records, tradertypes.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -447,7 +448,7 @@ func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]Closed
}
// GetTrades retrieves trade history from Lighter
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertypes.TradeRecord, error) {
// Ensure we have account index
if t.accountIndex == 0 {
if err := t.initializeAccount(); err != nil {
@@ -490,7 +491,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
if resp.StatusCode != http.StatusOK {
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
return []TradeRecord{}, nil
return []tradertypes.TradeRecord{}, nil
}
// Debug: log raw response
@@ -502,14 +503,14 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
var trades []LighterTrade
if err := json.Unmarshal(body, &trades); err != nil {
logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
return []TradeRecord{}, nil
return []tradertypes.TradeRecord{}, nil
}
response.Trades = trades
}
if response.Code != 200 && response.Code != 0 {
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
return []TradeRecord{}, nil
return []tradertypes.TradeRecord{}, nil
}
// Build market_id -> symbol map
@@ -528,7 +529,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
}
// Convert to unified TradeRecord format
var result []TradeRecord
var result []tradertypes.TradeRecord
for _, lt := range response.Trades {
price, _ := parseFloat(lt.Price)
qty, _ := parseFloat(lt.Size)
@@ -615,7 +616,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
openSide, openAction = "LONG", "open_long"
}
closeTrade := TradeRecord{
closeTrade := tradertypes.TradeRecord{
TradeID: fmt.Sprintf("%d_close", lt.TradeID),
Symbol: symbol,
Side: side,
@@ -629,7 +630,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
}
result = append(result, closeTrade)
openTrade := TradeRecord{
openTrade := tradertypes.TradeRecord{
TradeID: fmt.Sprintf("%d_open", lt.TradeID),
Symbol: symbol,
Side: side,
@@ -671,7 +672,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
}
}
trade := TradeRecord{
trade := tradertypes.TradeRecord{
TradeID: fmt.Sprintf("%d", lt.TradeID),
Symbol: symbol,
Side: side,

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"bytes"
@@ -13,6 +13,7 @@ import (
"time"
"github.com/elliottech/lighter-go/types"
tradertypes "nofx/trader/types"
)
// OpenLong Open long position (implements Trader interface)
@@ -856,14 +857,14 @@ func pow10(n int) int64 {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) {
// Get active orders from Lighter API
activeOrders, err := t.GetActiveOrders(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get active orders: %w", err)
}
var result []OpenOrder
var result []tradertypes.OpenOrder
for _, order := range activeOrders {
// Convert side: Lighter uses is_ask (true=sell, false=buy)
side := "BUY"
@@ -905,7 +906,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
}
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
openOrder := OpenOrder{
openOrder := tradertypes.OpenOrder{
OrderID: order.OrderID,
Symbol: symbol,
Side: side,
@@ -925,7 +926,7 @@ func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// PlaceLimitOrder implements GridTrader interface for grid trading
// Places a limit order at the specified price
func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*tradertypes.LimitOrderResult, error) {
if t.txClient == nil {
return nil, fmt.Errorf("TxClient not initialized")
}
@@ -960,7 +961,7 @@ func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderRe
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
req.Symbol, req.Side, req.Price, orderID)
return &LimitOrderResult{
return &tradertypes.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,

View File

@@ -1,4 +1,4 @@
package trader
package lighter
import (
"fmt"
@@ -7,6 +7,14 @@ import (
"golang.org/x/crypto/sha3"
)
// SymbolPrecision Symbol precision information
type SymbolPrecision struct {
PricePrecision int
QuantityPrecision int
TickSize float64 // Price tick size
StepSize float64 // Quantity step size
}
// AccountBalance Account balance information (Lighter)
type AccountBalance struct {
TotalEquity float64 `json:"total_equity"` // Total equity

View File

@@ -1,4 +1,4 @@
package trader
package okx
import (
"encoding/json"

View File

@@ -1,4 +1,4 @@
package trader
package okx
import (
"bytes"
@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"time"
"nofx/trader/types"
)
// OKX API endpoints
@@ -1281,7 +1282,7 @@ var okxTag = func() string {
// GetClosedPnL retrieves closed position PnL records from OKX
// OKX API: /api/v5/account/positions-history
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1328,10 +1329,10 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
return nil, fmt.Errorf("OKX API error: %s - %s", resp.Code, resp.Msg)
}
records := make([]ClosedPnLRecord, 0, len(resp.Data))
records := make([]types.ClosedPnLRecord, 0, len(resp.Data))
for _, pos := range resp.Data {
record := ClosedPnLRecord{}
record := types.ClosedPnLRecord{}
// Convert instrument ID to standard format (BTC-USDT-SWAP -> BTCUSDT)
parts := strings.Split(pos.InstID, "-")
@@ -1389,9 +1390,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
func (t *OKXTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
instId := t.convertSymbol(symbol)
var result []OpenOrder
var result []types.OpenOrder
// 1. Get pending limit orders
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
@@ -1422,7 +1423,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
positionSide = "BOTH"
}
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: order.OrdId,
Symbol: symbol,
Side: side,
@@ -1438,7 +1439,8 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
}
// 2. Get pending algo orders (stop-loss/take-profit)
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxAlgoPendingPath, instId)
// OKX requires ordType parameter for algo orders API
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP&ordType=conditional", okxAlgoPendingPath, instId)
algoData, err := t.doRequest("GET", algoPath, nil)
if err != nil {
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
@@ -1451,12 +1453,13 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
PosSide string `json:"posSide"`
OrdType string `json:"ordType"` // conditional/oco/trigger
TriggerPx string `json:"triggerPx"`
SlTriggerPx string `json:"slTriggerPx"` // Stop loss trigger price
TpTriggerPx string `json:"tpTriggerPx"` // Take profit trigger price
Sz string `json:"sz"`
State string `json:"state"`
}
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
for _, order := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
quantity, _ := strconv.ParseFloat(order.Sz, 64)
side := strings.ToUpper(order.Side)
@@ -1465,23 +1468,59 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
positionSide = "BOTH"
}
// Map OKX algo order type
orderType := "STOP_MARKET"
if order.OrdType == "oco" {
orderType = "TAKE_PROFIT_MARKET"
// Check for stop loss order (slTriggerPx is set)
if order.SlTriggerPx != "" {
slPrice, _ := strconv.ParseFloat(order.SlTriggerPx, 64)
if slPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_sl",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: slPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
result = append(result, OpenOrder{
OrderID: order.AlgoId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
// Check for take profit order (tpTriggerPx is set)
if order.TpTriggerPx != "" {
tpPrice, _ := strconv.ParseFloat(order.TpTriggerPx, 64)
if tpPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId + "_tp",
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "TAKE_PROFIT_MARKET",
Price: 0,
StopPrice: tpPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
// Fallback for trigger orders (triggerPx is set)
if order.TriggerPx != "" && order.SlTriggerPx == "" && order.TpTriggerPx == "" {
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
if triggerPrice > 0 {
result = append(result, types.OpenOrder{
OrderID: order.AlgoId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "STOP_MARKET",
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
}
}
}
@@ -1492,7 +1531,7 @@ func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
func (t *OKXTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
instId := t.convertSymbol(req.Symbol)
// Get instrument info
@@ -1566,7 +1605,7 @@ func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult,
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
instId, side, req.Price, orders[0].OrdId)
return &LimitOrderResult{
return &types.LimitOrderResult{
OrderID: orders[0].OrdId,
ClientID: orders[0].ClOrdId,
Symbol: req.Symbol,

View File

@@ -1,10 +1,11 @@
package trader
package testutil
import (
"testing"
"github.com/agiledragon/gomonkey/v2"
"github.com/stretchr/testify/assert"
"nofx/trader/types"
)
// TraderTestSuite Generic Trader interface test suite (base suite)
@@ -16,12 +17,12 @@ import (
// 3. Call RunAllTests() to run all generic tests
type TraderTestSuite struct {
T *testing.T
Trader Trader
Trader types.Trader
Patches *gomonkey.Patches
}
// NewTraderTestSuite Create new base test suite
func NewTraderTestSuite(t *testing.T, trader Trader) *TraderTestSuite {
func NewTraderTestSuite(t *testing.T, trader types.Trader) *TraderTestSuite {
return &TraderTestSuite{
T: t,
Trader: trader,

230
trader/types/interface.go Normal file
View File

@@ -0,0 +1,230 @@
package types
import (
"fmt"
"nofx/logger"
"time"
)
// ClosedPnLRecord represents a single closed position record from exchange
type ClosedPnLRecord struct {
Symbol string // Trading pair (e.g., "BTCUSDT")
Side string // "long" or "short"
EntryPrice float64 // Entry price
ExitPrice float64 // Exit/close price
Quantity float64 // Position size
RealizedPnL float64 // Realized profit/loss
Fee float64 // Trading fee/commission
Leverage int // Leverage used
EntryTime time.Time // Position open time
ExitTime time.Time // Position close time
OrderID string // Close order ID
CloseType string // "manual", "stop_loss", "take_profit", "liquidation", "unknown"
ExchangeID string // Exchange-specific position ID
}
// TradeRecord represents a single trade/fill from exchange
// Used for reconstructing position history with unified algorithm
type TradeRecord struct {
TradeID string // Unique trade ID from exchange
Symbol string // Trading pair (e.g., "BTCUSDT")
Side string // "BUY" or "SELL"
PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode)
OrderAction string // "open_long", "open_short", "close_long", "close_short" (from exchange Dir field)
Price float64 // Execution price
Quantity float64 // Executed quantity
RealizedPnL float64 // Realized PnL (non-zero for closing trades)
Fee float64 // Trading fee/commission
Time time.Time // Trade execution time
}
// Trader Unified trader interface
// Supports multiple trading platforms (Binance, Hyperliquid, etc.)
type Trader interface {
// GetBalance Get account balance
GetBalance() (map[string]interface{}, error)
// GetPositions Get all positions
GetPositions() ([]map[string]interface{}, error)
// OpenLong Open long position
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
// OpenShort Open short position
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
// CloseLong Close long position (quantity=0 means close all)
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
// CloseShort Close short position (quantity=0 means close all)
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
// SetLeverage Set leverage
SetLeverage(symbol string, leverage int) error
// SetMarginMode Set position mode (true=cross margin, false=isolated margin)
SetMarginMode(symbol string, isCrossMargin bool) error
// GetMarketPrice Get market price
GetMarketPrice(symbol string) (float64, error)
// SetStopLoss Set stop-loss order
SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error
// SetTakeProfit Set take-profit order
SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error
// CancelStopLossOrders Cancel only stop-loss orders (BUG fix: don't delete take-profit when adjusting stop-loss)
CancelStopLossOrders(symbol string) error
// CancelTakeProfitOrders Cancel only take-profit orders (BUG fix: don't delete stop-loss when adjusting take-profit)
CancelTakeProfitOrders(symbol string) error
// CancelAllOrders Cancel all pending orders for this symbol
CancelAllOrders(symbol string) error
// CancelStopOrders Cancel stop-loss/take-profit orders for this symbol (for adjusting stop-loss/take-profit positions)
CancelStopOrders(symbol string) error
// FormatQuantity Format quantity to correct precision
FormatQuantity(symbol string, quantity float64) (string, error)
// GetOrderStatus Get order status
// Returns: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission
GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error)
// GetClosedPnL Get closed position PnL records from exchange
// startTime: start time for query (usually last sync time)
// limit: max number of records to return
// Returns accurate exit price, fees, and close reason for positions closed externally
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
// GetOpenOrders Get open/pending orders from exchange
// Returns stop-loss, take-profit, and limit orders that haven't been filled
GetOpenOrders(symbol string) ([]OpenOrder, error)
}
// OpenOrder represents a pending order on the exchange
type OpenOrder struct {
OrderID string `json:"order_id"`
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
Price float64 `json:"price"` // Order price (for limit orders)
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW
}
// LimitOrderRequest represents a limit order request for grid trading
type LimitOrderRequest struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
Price float64 `json:"price"` // Limit price
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
PostOnly bool `json:"post_only"` // Maker only order
ReduceOnly bool `json:"reduce_only"` // Reduce position only
ClientID string `json:"client_id"` // Client order ID for tracking
}
// LimitOrderResult represents the result of placing a limit order
type LimitOrderResult struct {
OrderID string `json:"order_id"`
ClientID string `json:"client_id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PositionSide string `json:"position_side"`
Price float64 `json:"price"`
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
}
// GridTrader extends Trader interface with limit order support for grid trading
// Exchanges that support grid trading should implement this interface
type GridTrader interface {
Trader
// PlaceLimitOrder places a limit order at specified price
// Returns order ID and status
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
// CancelOrder cancels a specific order by ID
CancelOrder(symbol, orderID string) error
// GetOrderBook gets current order book (for price validation)
// Returns best bid/ask prices
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
}
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
// Uses stop orders as a fallback when limit orders aren't directly available
type GridTraderAdapter struct {
Trader
}
// NewGridTraderAdapter creates an adapter for basic Trader
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
return &GridTraderAdapter{Trader: t}
}
// PlaceLimitOrder implements limit order using available methods
// For exchanges without native limit order support, this uses conditional orders
func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// CRITICAL FIX: Set leverage before placing order
if req.Leverage > 0 {
if err := a.Trader.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Grid] Failed to set leverage %dx: %v", req.Leverage, err)
// Continue anyway - some exchanges don't require explicit leverage setting
}
}
// Use SetStopLoss/SetTakeProfit as conditional limit orders
// For buy orders below current price, use stop-loss mechanism
// For sell orders above current price, use take-profit mechanism
var err error
if req.Side == "BUY" {
err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price)
} else {
err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price)
}
if err != nil {
return nil, err
}
return &LimitOrderResult{
OrderID: req.ClientID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order
func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error {
// Try to use CancelOrder if trader supports it directly
if canceler, ok := a.Trader.(interface {
CancelOrder(symbol, orderID string) error
}); ok {
return canceler.CancelOrder(symbol, orderID)
}
// For traders that only support CancelAllOrders, log a warning
// This is a limitation - we cannot cancel individual orders
logger.Warnf("[Grid] Trader does not support individual order cancellation, "+
"cannot cancel order %s. Consider using exchange-specific GridTrader implementation.", orderID)
// Return error instead of canceling all orders
return fmt.Errorf("individual order cancellation not supported for this exchange")
}
// GetOrderBook returns empty order book (not supported in basic Trader)
func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
// Not supported, return empty
return nil, nil, nil
}

View File

@@ -0,0 +1,7 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1C1C28"/>
<g transform="translate(8, 8)">
<path d="M12 18.6c-3.64 0-6.6-2.96-6.6-6.6s2.96-6.6 6.6-6.6V0C5.37 0 0 5.38 0 12s5.37 12 12 12c6.62 0 12-5.38 12-12h-5.4c0 3.64-2.96 6.6-6.6 6.6z" fill="#2354e6"/>
<path d="M12 12h6.6V5.4H12z" fill="#17e6a1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -0,0 +1,6 @@
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="8" fill="#1C1C28"/>
<g transform="translate(5, 5) scale(0.15)">
<path d="M57.7007 99.9146L116.94 159.158L154.381 121.714C160.964 115.131 171.734 115.131 178.317 121.714C184.899 128.297 184.899 139.068 178.317 145.651L128.908 195.063C122.326 201.646 111.555 201.646 104.973 195.063L34.0221 123.937V166.339C34.0221 175.572 26.4997 183.351 17.0111 183.351C7.52258 183.351 0 175.828 0 166.339V34.003C0 24.5138 7.52258 16.9908 17.0111 16.9908C26.4997 16.9908 34.0221 24.5138 34.0221 34.003V76.0633L105.143 4.93695C111.726 -1.64565 122.496 -1.64565 129.079 4.93695L178.488 54.3492C185.07 60.9318 185.07 71.7034 178.488 78.286C171.905 84.8686 161.135 84.8686 154.552 78.286L117.111 40.8421L57.7007 100.085V99.9146ZM117.111 82.9024C107.622 82.9024 100.1 90.4254 100.1 99.9146C100.1 109.404 107.622 116.927 117.111 116.927C126.6 116.927 134.122 109.404 134.122 99.9146C133.951 90.4254 126.429 82.9024 117.111 82.9024Z" fill="#00B47D"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,4 +1,4 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Claude</title>
<path fill="#D97757" d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>
<title>Anthropic</title>
<path fill="#CC785C" d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -1384,6 +1384,99 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
)
}
// Step indicator component for Model Config
function ModelStepIndicator({ currentStep, labels }: { currentStep: number; labels: string[] }) {
return (
<div className="flex items-center justify-center gap-2 mb-6">
{labels.map((label, index) => (
<React.Fragment key={index}>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#8B5CF6' : '#2B3139',
color: index <= currentStep ? '#000' : '#848E9C',
}}
>
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
</div>
<span
className="text-xs font-medium hidden sm:block"
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
>
{label}
</span>
</div>
{index < labels.length - 1 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
/>
)}
</React.Fragment>
))}
</div>
)
}
// Model card component
function ModelCard({
model,
selected,
onClick,
configured,
}: {
model: AIModel
selected: boolean
onClick: () => void
configured?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center gap-2 p-4 rounded-xl transition-all hover:scale-105"
style={{
background: selected ? 'rgba(139, 92, 246, 0.15)' : '#0B0E11',
border: selected ? '2px solid #8B5CF6' : '2px solid #2B3139',
}}
>
<div className="relative">
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
{getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{model.name[0]}</span>
)}
</div>
{selected && (
<div
className="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: '#0ECB81' }}
>
<Check className="w-3 h-3 text-black" />
</div>
)}
{configured && !selected && (
<div
className="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center"
style={{ background: '#F0B90B' }}
>
<Check className="w-2.5 h-2.5 text-black" />
</div>
)}
</div>
<span className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{getShortName(model.name)}
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full uppercase tracking-wide"
style={{ background: 'rgba(139, 92, 246, 0.2)', color: '#A78BFA' }}
>
{model.provider}
</span>
</button>
)
}
// Model Configuration Modal Component
function ModelConfigModal({
allModels,
@@ -1407,17 +1500,16 @@ function ModelConfigModal({
onClose: () => void
language: Language
}) {
const [currentStep, setCurrentStep] = useState(editingModelId ? 1 : 0)
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
// 获取当前编辑的模型信息 - 编辑时从已配置的模型中查找,新建时从所有支持的模型中查找
const selectedModel = editingModelId
? configuredModels?.find((m) => m.id === selectedModelId)
: allModels?.find((m) => m.id === selectedModelId)
// 如果是编辑现有模型初始化API Key、Base URL和Model Name
useEffect(() => {
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
@@ -1426,266 +1518,239 @@ function ModelConfigModal({
}
}, [editingModelId, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
setCurrentStep(1)
}
const handleBack = () => {
if (editingModelId) {
onClose()
} else {
setCurrentStep(0)
setSelectedModelId('')
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedModelId || !apiKey.trim()) return
onSave(
selectedModelId,
apiKey.trim(),
baseUrl.trim() || undefined,
modelName.trim() || undefined
)
onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
}
// 可选择的模型列表(所有支持的模型)
const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = language === 'zh' ? ['选择模型', '配置 API'] : ['Select Model', 'Configure API']
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
<div
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
style={{
background: '#1E2329',
maxHeight: 'calc(100vh - 4rem)',
}}
className="rounded-2xl w-full max-w-2xl relative my-8 shadow-2xl"
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)', maxHeight: 'calc(100vh - 4rem)' }}
>
<div
className="flex items-center justify-between p-6 pb-4 sticky top-0 z-10"
style={{ background: '#1E2329' }}
>
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{editingModelId
? t('editAIModel', language)
: t('addAIModel', language)}
</h3>
{editingModelId && (
<button
type="button"
onClick={() => onDelete(editingModelId)}
className="p-2 rounded hover:bg-red-100 transition-colors"
style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}
title={t('delete', language)}
>
<Trash2 className="w-4 h-4" />
{/* Header */}
<div className="flex items-center justify-between p-6 pb-2">
<div className="flex items-center gap-3">
{currentStep > 0 && !editingModelId && (
<button type="button" onClick={handleBack} className="p-2 rounded-lg hover:bg-white/10 transition-colors">
<svg className="w-5 h-5" style={{ color: '#848E9C' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{editingModelId ? t('editAIModel', language) : t('addAIModel', language)}
</h3>
</div>
<div className="flex items-center gap-2">
{editingModelId && (
<button
type="button"
onClick={() => onDelete(editingModelId)}
className="p-2 rounded-lg hover:bg-red-500/20 transition-colors"
style={{ color: '#F6465D' }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button type="button" onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors" style={{ color: '#848E9C' }}>
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 pb-6">
<div
className="space-y-4 overflow-y-auto"
style={{ maxHeight: 'calc(100vh - 16rem)' }}
>
{!editingModelId && (
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('selectModel', language)}
</label>
<select
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
>
<option value="">{t('pleaseSelectModel', language)}</option>
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{getShortName(model.name)} ({model.provider})
</option>
))}
</select>
</div>
)}
{/* Step Indicator */}
{!editingModelId && (
<div className="px-6">
<ModelStepIndicator currentStep={currentStep} labels={stepLabels} />
</div>
)}
{selectedModel && (
<div
className="p-4 rounded"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 flex items-center justify-center">
{getModelIcon(selectedModel.provider || selectedModel.id, {
width: 32,
height: 32,
}) || (
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold"
style={{
background:
selectedModel.id === 'deepseek'
? '#60a5fa'
: '#c084fc',
color: '#fff',
}}
>
{selectedModel.name[0]}
</div>
)}
{/* Content */}
<div className="px-6 pb-6 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 16rem)' }}>
{/* Step 0: Select Model */}
{currentStep === 0 && !editingModelId && (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'}
</div>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => handleSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'}
</div>
</div>
)}
{/* Step 1: Configure */}
{(currentStep === 1 || editingModelId) && selectedModel && (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Selected Model Header */}
<div className="p-4 rounded-xl flex items-center gap-4" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
{getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || (
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{selectedModel.name[0]}</span>
)}
</div>
<div className="flex-1">
<div className="font-semibold text-lg" style={{ color: '#EAECEF' }}>
{getShortName(selectedModel.name)}
</div>
<div className="flex-1">
<div className="font-semibold" style={{ color: '#EAECEF' }}>
{getShortName(selectedModel.name)}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{selectedModel.provider} {selectedModel.id}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{selectedModel.provider} {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id}
</div>
</div>
{/* Default model info and API link */}
{AI_PROVIDER_CONFIG[selectedModel.provider] && (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid #2B3139' }}>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('defaultModel', language)}: <span style={{ color: '#F0B90B' }}>{AI_PROVIDER_CONFIG[selectedModel.provider].defaultModel}</span>
</div>
<a
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs hover:underline"
style={{ color: '#F0B90B' }}
>
<ExternalLink className="w-3 h-3" />
{t('applyApiKey', language)} {AI_PROVIDER_CONFIG[selectedModel.provider].apiName}
</a>
{selectedModel.provider === 'kimi' && (
<div className="mt-2 text-xs p-2 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
{t('kimiApiNote', language)}
</div>
)}
</div>
<a
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all hover:scale-105"
style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.3)' }}
>
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} />
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}>
{language === 'zh' ? '获取 API Key' : 'Get API Key'}
</span>
</a>
)}
</div>
)}
{selectedModel && (
<>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
API Key
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterAPIKey', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('customBaseURL', language)}
</label>
<input
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={t('customBaseURLPlaceholder', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
{/* Kimi Warning */}
{selectedModel.provider === 'kimi' && (
<div className="p-4 rounded-xl" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
<div className="flex items-start gap-2">
<span style={{ fontSize: '16px' }}></span>
<div className="text-sm" style={{ color: '#F6465D' }}>
{t('kimiApiNote', language)}
</div>
</div>
</div>
)}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={t('customModelNamePlaceholder', language)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
</div>
{/* API Key */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
API Key *
</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={t('enterAPIKey', language)}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
{/* Custom Base URL */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{t('customBaseURL', language)}
</label>
<input
type="url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={t('customBaseURLPlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
</div>
</div>
<div
className="p-4 rounded"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
{/* Custom Model Name */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder={t('customModelNamePlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
</div>
</div>
{/* Info Box */}
<div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#A78BFA' }}>
<Brain className="w-4 h-4" />
{t('information', language)}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div> {t('modelConfigInfo1', language)}</div>
<div> {t('modelConfigInfo2', language)}</div>
<div> {t('modelConfigInfo3', language)}</div>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={handleBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')}
</button>
<button
type="submit"
disabled={!selectedModel || !apiKey.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#8B5CF6', color: '#fff' }}
>
<div
className="text-sm font-semibold mb-2"
style={{ color: '#F0B90B' }}
>
{t('information', language)}
</div>
<div
className="text-xs space-y-1"
style={{ color: '#848E9C' }}
>
<div>{t('modelConfigInfo1', language)}</div>
<div>{t('modelConfigInfo2', language)}</div>
<div>{t('modelConfigInfo3', language)}</div>
</div>
</div>
</>
)}
</div>
<div
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
style={{ background: '#1E2329' }}
>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: '#2B3139', color: '#848E9C' }}
>
{t('cancel', language)}
</button>
<button
type="submit"
disabled={!selectedModel || !apiKey.trim()}
className="flex-1 px-4 py-2 rounded text-sm font-semibold disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('saveConfig', language)}
</button>
</div>
</form>
{t('saveConfig', language)}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</button>
</div>
</form>
)}
</div>
</div>
</div>
)

View File

@@ -12,6 +12,8 @@ const ICON_PATHS: Record<string, string> = {
bybit: '/exchange-icons/bybit.png',
okx: '/exchange-icons/okx.svg',
bitget: '/exchange-icons/bitget.svg',
gate: '/exchange-icons/gate.svg',
kucoin: '/exchange-icons/kucoin.svg',
hyperliquid: '/exchange-icons/hyperliquid.png',
aster: '/exchange-icons/aster.svg',
lighter: '/exchange-icons/lighter.png',
@@ -89,13 +91,17 @@ export const getExchangeIcon = (
? 'okx'
: lowerType.includes('bitget')
? 'bitget'
: lowerType.includes('hyperliquid')
? 'hyperliquid'
: lowerType.includes('aster')
? 'aster'
: lowerType.includes('lighter')
? 'lighter'
: lowerType
: lowerType.includes('gate')
? 'gate'
: lowerType.includes('kucoin')
? 'kucoin'
: lowerType.includes('hyperliquid')
? 'hyperliquid'
: lowerType.includes('aster')
? 'aster'
: lowerType.includes('lighter')
? 'lighter'
: lowerType
const iconProps = {
width: props.width || 24,

View File

@@ -334,6 +334,8 @@ export function LoginPage() {
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
aria-label={showPassword ? t('hidePassword', language) : t('showPassword', language)}
aria-pressed={showPassword}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>

View File

@@ -34,6 +34,8 @@ export default function FooterSection({ language }: FooterSectionProps) {
{ name: 'Bybit', href: 'https://partner.bybit.com/b/83856' },
{ name: 'OKX', href: 'https://www.okx.com/join/1865360' },
{ name: 'Bitget', href: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172' },
{ name: 'Gate.io', href: 'https://www.gatenode.xyz/share/VQBGUAxY' },
{ name: 'KuCoin', href: 'https://www.kucoin.com/r/broker/CXEV7XKK' },
{ name: 'Hyperliquid', href: 'https://app.hyperliquid.xyz/join/AITRADING' },
{ name: 'Aster DEX', href: 'https://www.asterdex.com/en/referral/fdfc0e' },
{ name: 'Lighter', href: 'https://app.lighter.xyz/?referral=68151432' },

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, X, Database, TrendingUp, List, Ban, Zap } from 'lucide-react'
import { Plus, X, Database, TrendingUp, TrendingDown, List, Ban, Zap, Shuffle } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
interface CoinSourceEditorProps {
@@ -23,27 +23,38 @@ export function CoinSourceEditor({
sourceType: { zh: '数据来源类型', en: 'Source Type' },
static: { zh: '静态列表', en: 'Static List' },
ai500: { zh: 'AI500 数据源', en: 'AI500 Data Provider' },
oi_top: { zh: 'OI Top 持仓增', en: 'OI Top' },
oi_top: { zh: 'OI 持仓增', en: 'OI Increase' },
oi_low: { zh: 'OI 持仓减少', en: 'OI Decrease' },
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 Top 数据', en: 'Enable OI Top' },
useOITop: { zh: '启用 OI 持仓增加榜', en: 'Enable OI Increase' },
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: 'Use coins with fastest OI growth',
zh: '持仓增加榜,适合做多',
en: 'OI increase ranking, for long',
},
oi_lowDesc: {
zh: '持仓减少榜,适合做空',
en: 'OI decrease ranking, for short',
},
mixedDesc: {
zh: '组合多种数据源AI500 + OI Top + 自定义',
en: 'Combine multiple sources: AI500 + OI Top + Custom',
zh: '组合多种数据源',
en: 'Combine multiple sources',
},
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' },
@@ -57,9 +68,35 @@ export function CoinSourceEditor({
{ value: 'static', icon: List, color: '#848E9C' },
{ value: 'ai500', icon: Database, color: '#F0B90B' },
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
{ value: 'mixed', icon: Database, color: '#60a5fa' },
{ value: 'oi_low', icon: TrendingDown, color: '#F6465D' },
{ value: 'mixed', icon: Shuffle, 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
@@ -156,7 +193,7 @@ export function CoinSourceEditor({
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('sourceType')}
</label>
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-5 gap-2">
{sourceTypes.map(({ value, icon: Icon, color }) => (
<button
key={value}
@@ -182,8 +219,8 @@ export function CoinSourceEditor({
</div>
</div>
{/* Static Coins */}
{(config.source_type === 'static' || config.source_type === 'mixed') && (
{/* Static Coins - only for static mode */}
{config.source_type === 'static' && (
<div>
<label className="block text-sm font-medium mb-3 text-nofx-text">
{t('staticCoins')}
@@ -283,8 +320,8 @@ export function CoinSourceEditor({
)}
</div>
{/* AI500 Options */}
{(config.source_type === 'ai500' || config.source_type === 'mixed') && (
{/* AI500 Options - only for ai500 mode */}
{config.source_type === 'ai500' && (
<div
className="p-4 rounded-lg bg-nofx-gold/5 border border-nofx-gold/20"
>
@@ -340,8 +377,8 @@ export function CoinSourceEditor({
</div>
)}
{/* OI Top Options */}
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
{/* OI Top Options - only for oi_top mode */}
{config.source_type === 'oi_top' && (
<div
className="p-4 rounded-lg bg-nofx-success/5 border border-nofx-success/20"
>
@@ -349,7 +386,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 Top {t('dataSourceConfig')}
OI {language === 'zh' ? '持仓增加榜' : 'Increase'} {t('dataSourceConfig')}
</span>
<NofxOSBadge />
</div>
@@ -375,10 +412,10 @@ export function CoinSourceEditor({
{t('oiTopLimit')}:
</span>
<select
value={config.oi_top_limit || 20}
value={config.oi_top_limit || 10}
onChange={(e) =>
!disabled &&
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
onChange({ ...config, oi_top_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"
@@ -396,6 +433,306 @@ 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

@@ -1,4 +1,4 @@
import { Grid, DollarSign, TrendingUp, Shield } from 'lucide-react'
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
import type { GridStrategyConfig } from '../../types'
interface GridConfigEditorProps {
@@ -23,6 +23,8 @@ export const defaultGridConfig: GridStrategyConfig = {
stop_loss_pct: 5,
daily_loss_limit_pct: 10,
use_maker_only: true,
enable_direction_adjust: false,
direction_bias_ratio: 0.7,
}
export function GridConfigEditor({
@@ -47,7 +49,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-20)', en: 'Leverage for trading (1-20)' },
leverageDesc: { zh: '交易使用的杠杆倍数 (1-5)', en: 'Leverage for trading (1-5)' },
// Grid parameters
gridCount: { zh: '网格数量', en: 'Grid Count' },
@@ -77,6 +79,14 @@ export function GridConfigEditor({
dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage' },
useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders' },
useMakerOnlyDesc: { zh: '使用限价单以降低手续费', en: 'Use limit orders for lower fees' },
// Direction adjustment
directionAdjust: { zh: '方向自动调整', en: 'Direction Auto-Adjust' },
enableDirectionAdjust: { zh: '启用方向调整', en: 'Enable Direction Adjust' },
enableDirectionAdjustDesc: { zh: '根据箱体突破自动调整网格方向(做多/做空/偏多/偏空)', en: 'Auto-adjust grid direction based on box breakouts (long/short/long_bias/short_bias)' },
directionBiasRatio: { zh: '偏向比例', en: 'Bias Ratio' },
directionBiasRatioDesc: { zh: '偏多/偏空模式下的买卖比例(如 0.7 表示 70% 买 + 30% 卖)', en: 'Buy/sell ratio for bias modes (e.g., 0.7 = 70% buy + 30% sell)' },
directionExplain: { zh: '短期箱体突破 → 偏向,中期箱体突破 → 全仓,价格回归 → 逐步恢复中性', en: 'Short box breakout → bias, Mid box breakout → full, Price return → gradually recover to neutral' },
}
return translations[key]?.[language] || key
}
@@ -171,7 +181,7 @@ export function GridConfigEditor({
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
disabled={disabled}
min={1}
max={20}
max={5}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
@@ -419,6 +429,77 @@ export function GridConfigEditor({
</div>
</div>
</div>
{/* Direction Auto-Adjust */}
<div>
<div className="flex items-center gap-2 mb-4">
<Compass className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{t('directionAdjust')}
</h3>
</div>
{/* Enable Toggle */}
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm" style={{ color: '#EAECEF' }}>
{t('enableDirectionAdjust')}
</label>
<p className="text-xs" style={{ color: '#848E9C' }}>
{t('enableDirectionAdjustDesc')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enable_direction_adjust ?? false}
onChange={(e) => updateField('enable_direction_adjust', e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
</label>
</div>
</div>
{config.enable_direction_adjust && (
<>
{/* Direction Explanation */}
<div className="p-3 rounded-lg mb-4" style={{ background: '#1E2329', border: '1px solid #F0B90B33' }}>
<p className="text-xs" style={{ color: '#F0B90B' }}>
💡 {t('directionExplain')}
</p>
</div>
{/* Bias Ratio */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{t('directionBiasRatio')}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('directionBiasRatioDesc')}
</p>
<div className="flex items-center gap-3">
<input
type="range"
value={(config.direction_bias_ratio ?? 0.7) * 100}
onChange={(e) => updateField('direction_bias_ratio', parseInt(e.target.value) / 100)}
disabled={disabled}
min={55}
max={90}
step={5}
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer"
style={{ background: '#2B3139' }}
/>
<span className="text-sm font-mono w-16 text-right" style={{ color: '#F0B90B' }}>
{Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% / {Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}%
</span>
</div>
</div>
</>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -667,6 +667,8 @@ export const translations = {
passwordRequired: 'Password is required',
invalidEmail: 'Invalid email format',
passwordTooShort: 'Password must be at least 6 characters',
showPassword: 'Show password',
hidePassword: 'Hide password',
// Landing Page
features: 'Features',
@@ -1834,6 +1836,8 @@ export const translations = {
passwordRequired: '请输入密码',
invalidEmail: '邮箱格式不正确',
passwordTooShort: '密码至少需要6个字符',
showPassword: '显示密码',
hidePassword: '隐藏密码',
// Landing Page
features: '功能',

View File

@@ -506,16 +506,22 @@ export interface GridStrategyConfig {
daily_loss_limit_pct: number;
// Use maker-only orders for lower fees
use_maker_only: boolean;
// Enable automatic grid direction adjustment based on box breakouts
enable_direction_adjust?: boolean;
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
direction_bias_ratio?: number;
}
export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'mixed';
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | '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
}