93 Commits

Author SHA1 Message Date
tinkle-community
79a21890d8 fix(web): fix TypeScript build errors in AuthContext and translations 2026-03-08 00:30:34 +08:00
Hansen1018
bbd72c778c Update OpenAI default model from gpt-5.2 to gpt-5.4 (#1402) 2026-03-08 00:22:16 +08:00
tinkle-community
73f1fe105d refactor(auth): remove OTP flows from login/register/reset 2026-03-05 18:55:36 +08:00
tinkle-community
fa664ccae3 docs: use main OFFICIAL_ACCOUNTS.md 2026-03-05 18:35:27 +08:00
tinkle-community
0210d0e4b5 Merge branch 'main' into dev
# Conflicts:
#	docs/community/OFFICIAL_ACCOUNTS.md
2026-03-05 18:31:32 +08:00
Muhammad Syaiful Anwar
27a7491cd1 feat(trader): add Indodax exchange integration (#1400)
* feat(trader): add Indodax exchange integration

- Add IndodaxTrader implementing types.Trader interface for spot trading
- Support HMAC-SHA512 authentication with Key/Sign headers
- Map spot buy/sell to OpenLong/CloseLong, stub futures-only methods
- Wire up auto_trader.go, trader_manager.go, store/exchange.go
- Add Indodax to frontend ExchangeConfigModal and ExchangeIcons
- Add integration tests with env-var based credentials
- Add Indodax logo assets (PNG + SVG)

* fix: type validation at server.go for indodax exchange
2026-03-03 18:41:50 +08:00
Muhammad Syaiful Anwar
3358c5a53e feat(i18n): add Indonesian (Bahasa Indonesia) language support (#1399)
- Add 'id' to Language type in translations.ts
- Add ~1000 Indonesian translation keys covering all UI sections
- Update LanguageContext to persist 'id' in localStorage
- Add ID button to Header.tsx language toggle
- Add �� option to HeaderBar.tsx desktop dropdown and mobile toggle
- Add Indonesian translations to inline text objects in
  LoginRequiredOverlay, StrategyMarketPage, PositionHistory

Closes #XX
2026-03-03 18:39:09 +08:00
Minho Yi
285053b7a4 fix(web): cycle number not showing (#1398) 2026-03-03 18:30:46 +08:00
tinkle-community
c7039e6b4a Official Accounts 2026-02-28 04:10:50 +08:00
Hao Fu
06d6080751 feat(hyperliquid): Add Unified Account support for Spot as Perp collateral (#1387)
This PR adds support for Hyperliquid's Unified Account mode where Spot USDC
balance can be used as collateral for Perpetual trading.

Changes:
- Add HyperliquidUnifiedAcct field to Exchange config (default: true)
- Update HyperliquidTrader to support unified account mode
- When enabled, Spot USDC balance is added to available trading balance
- Update API request/response structs for unified account toggle
- Update trader config propagation from exchange config

This aligns with Hyperliquid's roadmap to make Unified Account the default.
2026-02-22 17:03:21 +08:00
Hao Fu
64935b9d47 feat(strategy): Add Hyperliquid coin sources (hyper_all, hyper_main) (#1388)
Add two new coin source options for Hyperliquid trading:

- hyper_all: All available Hyperliquid perpetual coins (229 coins)
- hyper_main: Top N coins by 24h volume (default 20)

Changes:
- Add CoinSourceConfig fields: UseHyperAll, UseHyperMain, HyperMainLimit
- Add provider/hyperliquid/coins.go with caching (24h) and volume-based sorting
- Add source types 'hyper_all' and 'hyper_main' to GetCandidateCoins()
- Support mixing with other sources in 'mixed' mode
- Add source tag formatting for UI display

This ensures traders using Hyperliquid can select coins that are actually
available on the exchange, avoiding 'symbol not found' errors.
2026-02-22 17:03:05 +08:00
tinkle-community
0000bc7f32 docs: move and optimize OFFICIAL_ACCOUNTS.md to docs/community
- Moved from project root to docs/community/ for better organization
- Added Telegram community channel to official accounts list
- Made all links clickable with proper markdown formatting
- Added direct GitHub issue link for impersonation reports
- Added navigation links and reference from community README
2026-02-20 23:03:50 +08:00
tinkle-community
bdb2744845 docs: move and optimize OFFICIAL_ACCOUNTS.md to docs/community
- Moved from project root to docs/community/ for better organization
- Added Telegram community channel to official accounts list
- Made all links clickable with proper markdown formatting
- Added direct GitHub issue link for impersonation reports
- Added navigation links and reference from community README
2026-02-20 23:03:01 +08:00
Alxy Savin
4c525c19c6 feat(i18n): add consolidated translation keys for strategy components (#1375)
* feat(i18n): add 42 translation keys for TraderConfigModal

- Add new translation keys for all hardcoded Chinese strings
- Replace hardcoded UI text with t('key', language) calls
- Support both English and Chinese languages

Modified files:
- web/src/i18n/translations.ts: +88 lines (42 new keys)
- web/src/components/TraderConfigModal.tsx: replaced 48 hardcoded strings

* feat(i18n): add consolidated translation keys (en + zh + es)

- 275+ translation keys from 8 strategy components
- 3 languages: English, Chinese, Spanish
- Ready for integration into translations.ts
- Pre-aggregated exports for zhStrategy, enStrategy, esStrategy

Related to PR #1343 (maker95) and #1374 (xsa-dev)
2026-02-09 10:48:17 +08:00
Alxy Savin
95daa39f0b feat(i18n): add 42 translation keys for TraderConfigModal (#1374)
- Add new translation keys for all hardcoded Chinese strings
- Replace hardcoded UI text with t('key', language) calls
- Support both English and Chinese languages

Modified files:
- web/src/i18n/translations.ts: +88 lines (42 new keys)
- web/src/components/TraderConfigModal.tsx: replaced 48 hardcoded strings
2026-02-09 10:46:30 +08:00
tinkle-community
24700d3a73 chore: upgraded Claude model to Opus 4.6
- Update mcp/claude_client.go default model
- Update api/server.go supported models list
- Update web AITradersPage.tsx default model
2026-02-08 14:06:39 +08:00
tinkle-community
ec582a6ec4 feat(web): improve grid direction adjustment UI clarity
- Rename 'Bias Ratio' to 'Bias Strength' (偏向强度)
- Add direction modes explanation (neutral/long/short/long_bias/short_bias)
- Show actual buy/sell ratios for both long_bias and short_bias modes
- Add bilingual support (Chinese/English)
- Clarify that X% applies differently to long_bias vs short_bias
2026-02-06 14:59:12 +08:00
tinkle-community
9bfa56e226 chore: update GitHub stats defaults (stars 10,500+, forks 2,800+, community 6,600+) 2026-02-06 02:32:29 +08:00
tinkle-community
9ef67bdcd8 chore: update AI model display to Claude Opus 4.6 2026-02-06 02:22:12 +08:00
tinkle-community
77d45690a6 chore: update AI model to Claude Opus 4.6 2026-02-06 02:20:43 +08:00
tinkle-community
b70b047f75 feat(web): add Agent Terminal panel to landing page
- Add AgentTerminal component with trading dashboard UI
- Display Portfolio PnL, metrics, order book, positions
- macOS-style terminal header with window controls
- Integrate into TerminalHero right column
- Remove unnecessary glow effects for cleaner look
2026-02-06 02:13:13 +08:00
tinkle-community
8896de2642 chore: remove broken test file 2026-02-06 00:47:51 +08:00
tinkle-community
eb89a49b58 test: add unit tests for Gate trade expansion 2026-02-06 00:47:25 +08:00
tinkle-community
0b4f43d72b fix: adaptive price precision for meme coins
- Add adaptivePriceRound() in store/position.go for database storage
- Update position_builder.go to use adaptive precision for entry/exit prices
- Add Gate to OrderSync skip list in auto_trader.go
- Add debug logging in gate/order_sync.go for price parsing issues
- Create web/src/utils/format.ts with formatPrice() for frontend display
- Update TraderDashboardPage.tsx and PositionHistory.tsx to use adaptive formatting

Fixes issue where meme coin prices (e.g. 0.000000166) displayed as 0.0000
2026-02-06 00:46:48 +08: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
tinkle-community
aa6168afe3 fix(web): add LoginRequiredOverlay to Data page 2026-01-17 23:48:00 +08:00
tinkle-community
917a16381f fix(web): fix navigation from Data page using window.location.href 2026-01-17 23:44:52 +08:00
tinkle-community
7db84d57d3 fix(web): add data route to LandingPage navigation 2026-01-17 23:42:44 +08:00
tinkle-community
95486173f7 feat(web): add Data page with embedded nofxos.ai dashboard
- Add Data navigation item before Market in header
- Create DataPage component with iframe embedding
- Publicly accessible without login required
2026-01-17 23:37:12 +08:00
tinkle-community
ee081ebc85 docs: add official website links to all README files
- Official Website: https://nofxai.com
- Data Dashboard: https://nofxos.ai/dashboard
- API Documentation: https://nofxos.ai/api-docs

Updated: EN, ZH-CN, JA, KO, RU, UK, VI
2026-01-17 23:18:37 +08:00
SkywalkerJi
502801777f docs: update PR templates to English-only (#1332) 2026-01-12 22:50:03 -06:00
SkywalkerJi
b10b9ec1a7 docs: convert PR templates to English-only (#1331) 2026-01-12 22:06:17 -06:00
tinkle-community
c1def0e2c2 fix: change GAMMA-RAY risk level from ZERO to LOW 2026-01-13 10:36:27 +08:00
tinkle-community
705aa641b0 fix: backtest module PostgreSQL compatibility and bug fixes
- Fix PostgreSQL placeholder conversion (? to $1, $2...) in all SQL queries
- Fix int4 overflow for timestamp columns (ALTER to BIGINT)
- Fix notional calculation bug in position Close() using proportional entry
- Fix potential panic in DecisionTimestamp with bounds check
- Fix nil pointer dereference in sliceUpTo with defensive checks
- Fix race condition in releaseLock using sync.Once
- Fix UnrealizedPnLPct always 0 in convertPositions
- Improve Sharpe ratio calculation with proper negative return handling
2026-01-09 01:48:02 +08:00
tinkle-community
2f88205231 fix: chart container height using flexbox layout 2026-01-08 15:48:33 +08:00
tinkle-community
e92222950a fix: use completeRegistration for incomplete OTP setup in login flow
- LoginPage: call completeRegistration instead of verifyOTP when qrCodeURL exists
- This ensures otp_verified is set to true for users completing OTP setup
- Backend: reorder maxUsers check to allow existing incomplete users to continue
- Backend: return OTP info when login with incomplete OTP setup
2026-01-07 20:15:27 +08:00
tinkle-community
138943d6fb fix: update xyz dex order routing configuration 2026-01-07 02:31:52 +08:00
tinkle-community
b36ab27b65 feat: add pending orders (SL/TP) display on chart
- Add GetOpenOrders method to Trader interface
- Implement for Binance (legacy + Algo), Bybit, Hyperliquid
- Add stub implementations for OKX, Bitget, Aster, Lighter
- Add /api/open-orders endpoint
- Display price lines for SL (red) and TP (green) orders
- Refresh open orders every 60 seconds (separate from 5s kline refresh)
2026-01-07 00:50:29 +08:00
tinkle-community
5e65ae7077 fix: chart order markers not displaying due to timestamp format mismatch
- Fix milliseconds to seconds conversion in parseCustomTime (AdvancedChart & ChartWithOrders)
- Add GetTraderOrdersFiltered to filter orders at database level by symbol/status
- Increase order limit from 50 to 200 for more historical orders
- Group multiple orders at same candle time and show count (B3, S5, etc.)
- Buy markers shown below bar (green), sell markers above bar (red)
2026-01-06 21:08:42 +08:00
tinkle-community
c0c89d7534 docs: update Railway deploy button with official template URL 2026-01-06 19:07:25 +08:00
tinkle-community
3b2a3f4e76 chore: clean up Railway deployment - remove debug code 2026-01-06 18:58:27 +08:00
tinkle-community
c8458ec79c fix: align PORT defaults to 8080 for Railway 2026-01-06 18:53:27 +08:00
tinkle-community
aee096ab1e debug: test nginx startup and internal health check 2026-01-06 18:48:11 +08:00
tinkle-community
165c0b1b5d debug: add nginx config test and file check 2026-01-06 18:44:24 +08:00
tinkle-community
4c097f7190 fix: use heredoc for nginx config to avoid envsubst issues 2026-01-06 18:41:08 +08:00
tinkle-community
ea763a2471 fix: use port 8081 for backend to avoid conflict with nginx 2026-01-06 18:37:18 +08:00
tinkle-community
6e6bdf1e57 refactor: simplify Railway deployment using existing GHCR images
- Use multi-stage build from existing backend/frontend images
- Remove supervisord, use simple shell script
- Single process model: backend runs in background, nginx foreground
- Auto-generate encryption keys on startup
2026-01-06 18:31:39 +08:00
tinkle-community
f0b4913ad6 debug: add PORT environment variable debugging 2026-01-06 18:19:28 +08:00
tinkle-community
29cd79c626 fix: use Railway PORT env var for nginx 2026-01-06 18:07:11 +08:00
tinkle-community
7db37ade1c fix: auto-generate encryption keys in Railway startup script 2026-01-06 17:59:29 +08:00
tinkle-community
4804cfcb05 feat: add Railway one-click deployment support
- Add Dockerfile.railway for all-in-one container
- Add railway.toml configuration
- Add railway/nginx.conf and supervisord.conf
- Update README with Deploy on Railway button
- Update Chinese README with deployment instructions
2026-01-06 17:32:09 +08:00
tinkle-community
799d8b9c2e feat: migrate timestamps to int64 and security improvements
- Convert all time.Time fields to int64 Unix milliseconds (UTC)
- Add PostgreSQL migration to convert timestamp columns to bigint
- Reduce Binance sync window from 7 days to 24 hours
- Fix dashboard trader name visibility (add nofx-text-main color)
- Add position value column to history table
- Remove hardcoded API keys from test files
2026-01-06 15:56:07 +08:00
tinkle-community
5c4c9cdc99 fix: handle large Binance trade IDs in Go to avoid database CAST limitations 2026-01-06 10:43:21 +08:00
tinkle-community
8b86d4d85c docs: add prerequisites section and reorganize README structure across all languages 2026-01-06 08:16:00 +08:00
tinkle-community
962df5c3ed feat: add strategy description input field 2026-01-05 00:08:51 +08:00
tinkle-community
9f3de6e3c0 fix: resolve hyperliquid order execution approval issue 2026-01-04 22:27:15 +08:00
tinkle-community
5c9e134e99 fix: ensure all timestamps use UTC timezone
- Add NowFunc to GORM config for UTC auto-generated timestamps
- Add .UTC() to all time.UnixMilli() calls in trader files
- Add .UTC() to all time.Now() calls in store and api files
- Fix TypeScript unused imports in frontend
2026-01-04 20:03:56 +08:00
163 changed files with 26130 additions and 5067 deletions

View File

@@ -1,16 +1,16 @@
# PR 标题指南 # PR Title Guide
## 📋 概述 ## 📋 Overview
我们使用 **Conventional Commits** 格式来保持 PR 标题的一致性,但这是**建议性的**,不会阻止你的 PR 被合并。 We use the **Conventional Commits** format to maintain consistency in PR titles, but this is **recommended**, not mandatory. It will not prevent your PR from being merged.
## ✅ 推荐格式 ## ✅ Recommended Format
``` ```
type(scope): description type(scope): description
``` ```
### 示例 ### Examples
``` ```
feat(trader): add new trading strategy feat(trader): add new trading strategy
@@ -22,63 +22,63 @@ ci(workflow): improve GitHub Actions
--- ---
## 📖 详细说明 ## 📖 Detailed Guide
### Type(类型)- 必需 ### Type - Required
描述这次变更的类型: Describes the type of change:
| Type | 说明 | 示例 | | Type | Description | Example |
|------|------|------| |------|-------------|---------|
| `feat` | 新功能 | `feat(trader): add stop-loss feature` | | `feat` | New feature | `feat(trader): add stop-loss feature` |
| `fix` | Bug 修复 | `fix(api): handle null response` | | `fix` | Bug fix | `fix(api): handle null response` |
| `docs` | 文档变更 | `docs: update installation guide` | | `docs` | Documentation change | `docs: update installation guide` |
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` | | `style` | Code formatting (no functional change) | `style: format code with prettier` |
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` | | `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` | | `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
| `test` | 添加或修改测试 | `test(trader): add unit tests` | | `test` | Add or modify tests | `test(trader): add unit tests` |
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` | | `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` | | `ci` | CI/CD related changes | `ci: add test coverage report` |
| `security` | 安全相关修复 | `security: update vulnerable dependencies` | | `security` | Security fixes | `security: update vulnerable dependencies` |
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` | | `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
### Scope(范围)- 可选 ### Scope - Optional
描述这次变更影响的范围: Describes the area affected by the change:
| Scope | 说明 | | Scope | Description |
|-------|------| |-------|-------------|
| `exchange` | 交易所相关 | | `exchange` | Exchange-related |
| `trader` | 交易员/交易策略 | | `trader` | Trader/trading strategy |
| `ai` | AI 模型相关 | | `ai` | AI model related |
| `api` | API 接口 | | `api` | API interface |
| `ui` | 用户界面 | | `ui` | User interface |
| `frontend` | 前端代码 | | `frontend` | Frontend code |
| `backend` | 后端代码 | | `backend` | Backend code |
| `security` | 安全相关 | | `security` | Security related |
| `deps` | 依赖项 | | `deps` | Dependencies |
| `workflow` | GitHub Actions workflows | | `workflow` | GitHub Actions workflows |
| `github` | GitHub 配置 | | `github` | GitHub configuration |
| `actions` | GitHub Actions | | `actions` | GitHub Actions |
| `config` | 配置文件 | | `config` | Configuration files |
| `docker` | Docker 相关 | | `docker` | Docker related |
| `build` | 构建相关 | | `build` | Build related |
| `release` | 发布相关 | | `release` | Release related |
**注意:** 如果变更影响多个范围,可以省略 scope 或选择最主要的。 **Note:** If the change affects multiple scopes, you can omit the scope or choose the most relevant one.
### Description(描述)- 必需 ### Description - Required
- 使用现在时态("add" 而不是 "added" - Use present tense ("add" not "added")
- 首字母小写 - Start with lowercase
- 结尾不加句号 - No period at the end
- 简洁明了地描述变更内容 - Concisely describe what changed
--- ---
## 🎯 完整示例 ## 🎯 Complete Examples
### ✅ 好的 PR 标题 ### ✅ Good PR Titles
``` ```
feat(trader): add risk management system feat(trader): add risk management system
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
build(docker): optimize Docker image size build(docker): optimize Docker image size
``` ```
### ⚠️ 需要改进的标题 ### ⚠️ Titles That Need Improvement
| 不好的标题 | 问题 | 改进后 | | Poor Title | Issue | Improved |
|-----------|------|--------| |-----------|-------|----------|
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` | | `update code` | Too vague | `refactor(trader): simplify order execution logic` |
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` | | `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` | | `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
| `changes` | 完全不符合格式 | `chore: update dependencies` | | `changes` | Doesn't follow format | `chore: update dependencies` |
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` | | `feat: Added new trading algo` | Wrong tense | `feat(trader): add new trading algorithm` |
--- ---
## 🤖 自动检查行为 ## 🤖 Automated Check Behavior
### 当 PR 标题不符合格式时 ### When PR Title Doesn't Follow Format
1. **不会阻止合并** 1. **Won't block merging**
- 检查会标记为"建议" - Check is marked as "advisory"
- PR 仍然可以被审查和合并 - PR can still be reviewed and merged
2. **会收到友好提示** 💬 2. **Provides friendly reminder** 💬
- 机器人会在 PR 中留言 - Bot will comment on the PR
- 提供格式说明和示例 - Provides format guidance and examples
- 建议如何改进标题 - Suggests how to improve the title
3. **可以随时更新** 🔄 3. **Can be updated anytime** 🔄
- 更新 PR 标题后会重新检查 - Re-checks after updating PR title
- 无需关闭和重新打开 PR - No need to close and reopen PR
### 示例评论 ### Example Comment
如果你的 PR 标题是 `update workflow`,你会收到这样的评论: If your PR title is `update workflow`, you'll receive a comment like this:
```markdown ```markdown
## ⚠️ PR Title Format Suggestion ## ⚠️ PR Title Format Suggestion
@@ -157,11 +157,11 @@ Your PR can still be reviewed and merged.
--- ---
## 🔧 配置详情 ## 🔧 Configuration Details
### 支持的 Types ### Supported Types
`.github/workflows/pr-checks.yml` 中配置: Configured in `.github/workflows/pr-checks.yml`:
```yaml ```yaml
types: | types: |
@@ -178,7 +178,7 @@ types: |
build build
``` ```
### 支持的 Scopes ### Supported Scopes
```yaml ```yaml
scopes: | scopes: |
@@ -200,38 +200,38 @@ scopes: |
release release
``` ```
### 添加新的 Scope ### Adding New Scopes
如果你需要添加新的 scope,请: If you need to add a new scope:
1. `.github/workflows/pr-checks.yml``scopes` 部分添加 1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
2. `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选) 2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
3. 更新本文档 3. Update this documentation
--- ---
## 📚 为什么使用 Conventional Commits ## 📚 Why Use Conventional Commits?
### 优点 ### Benefits
1. **自动化 Changelog** 📝 1. **Automated Changelog** 📝
- 可以自动生成版本更新日志 - Automatically generate version changelogs
- 清晰地分类各种变更 - Clearly categorize different types of changes
2. **语义化版本** 🔢 2. **Semantic Versioning** 🔢
- `feat` → MINOR 版本(1.1.0 - `feat` → MINOR version (1.1.0)
- `fix` → PATCH 版本(1.0.1 - `fix` → PATCH version (1.0.1)
- `BREAKING CHANGE` → MAJOR 版本(2.0.0 - `BREAKING CHANGE` → MAJOR version (2.0.0)
3. **更好的可读性** 👀 3. **Better Readability** 👀
- 一眼看出 PR 的目的 - Understand PR purpose at a glance
- 更容易浏览 Git 历史 - Easier to browse Git history
4. **团队协作** 🤝 4. **Team Collaboration** 🤝
- 统一的提交风格 - Unified commit style
- 降低沟通成本 - Reduces communication overhead
### 示例:自动生成的 Changelog ### Example: Auto-generated Changelog
```markdown ```markdown
## v1.2.0 (2025-11-02) ## v1.2.0 (2025-11-02)
@@ -250,9 +250,9 @@ scopes: |
--- ---
## 🎓 学习资源 ## 🎓 Learning Resources
- **Conventional Commits 官网:** https://www.conventionalcommits.org/ - **Conventional Commits:** https://www.conventionalcommits.org/
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit - **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
- **Semantic Versioning:** https://semver.org/ - **Semantic Versioning:** https://semver.org/
@@ -260,33 +260,33 @@ scopes: |
## ❓ FAQ ## ❓ FAQ
### Q: 我必须遵循这个格式吗? ### Q: Must I follow this format?
**A:** 不必须。这是建议性的,不会阻止你的 PR 被合并。但遵循格式可以提高项目的可维护性。 **A:** No. This is recommended but not mandatory. It won't block your PR from being merged. However, following the format improves project maintainability.
### Q: 如果我忘记了怎么办? ### Q: What if I forget?
**A:** 机器人会在 PR 中提醒你,你可以随时更新标题。 **A:** The bot will remind you in the PR comments. You can update the title anytime.
### Q: 我可以在一个 PR 中做多种类型的变更吗? ### Q: Can I make multiple types of changes in one PR?
**A:** 可以,但建议: **A:** Yes, but it's recommended to:
- 选择最主要的类型 - Choose the most significant type
- 或者考虑拆分成多个 PR更易于审查 - Or consider splitting into multiple PRs (easier to review)
### Q: Scope 可以省略吗? ### Q: Can I omit the scope?
**A:** 可以。`requireScope: false` 表示 scope 是可选的。 **A:** Yes. `requireScope: false` means scope is optional.
示例:`docs: update README` (没有 scope 也可以) Example: `docs: update README` (no scope is fine)
### Q: 我想添加新的 type scope,怎么做? ### Q: How do I add a new type or scope?
**A:** 提一个 PR 修改 `.github/workflows/pr-checks.yml`,并在本文档中说明新增项的用途。 **A:** Submit a PR to modify `.github/workflows/pr-checks.yml` and document the purpose of the new item in this guide.
### Q: Breaking Changes 怎么表示? ### Q: How do I indicate Breaking Changes?
**A:** 在描述中添加 `BREAKING CHANGE:` 或在 type 后加 `!` **A:** Add `BREAKING CHANGE:` in the description or add `!` after the type:
``` ```
feat!: remove deprecated API feat!: remove deprecated API
@@ -297,9 +297,9 @@ BREAKING CHANGE: The old /auth endpoint is removed
--- ---
## 📊 统计 ## 📊 Statistics
想看项目的 commit 类型分布?运行: Want to see the commit type distribution in your project? Run:
```bash ```bash
git log --oneline --no-merges | \ git log --oneline --no-merges | \
@@ -309,14 +309,14 @@ git log --oneline --no-merges | \
--- ---
## ✅ 快速检查清单 ## ✅ Quick Checklist
在提交 PR 前,检查你的标题是否: Before submitting a PR, check if your title:
- [ ] 包含有效的 typefeat, fix, docs 等) - [ ] Contains a valid type (feat, fix, docs, etc.)
- [ ] 使用小写字母开头 - [ ] Starts with lowercase
- [ ] 使用现在时态("add" 而不是 "added" - [ ] Uses present tense ("add" not "added")
- [ ] 简洁明了(最好在 50 字符内) - [ ] Is concise (preferably under 50 characters)
- [ ] 准确描述了变更内容 - [ ] Accurately describes the change
**记住:** 这些都是建议,不是强制要求! **Remember:** These are recommendations, not requirements!

View File

@@ -1,104 +1,100 @@
# Pull Request | PR 提交 # Pull Request
> **📋 选择专用模板 | Choose Specialized Template** > **📋 Choose Specialized Template**
> >
> 我们现在提供了针对不同类型PR的专用模板帮助你更快速地填写PR信息
> We now offer specialized templates for different types of PRs to help you fill out the information faster: > We now offer specialized templates for different types of PRs to help you fill out the information faster:
> >
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes > - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes > - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates > - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes > - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
> >
> **如何使用?| How to use?** > **How to use?**
> - 创建PR时在URL中添加 `?template=backend.md` 或其他模板名称
> - When creating a PR, add `?template=backend.md` or other template name to the URL > - When creating a PR, add `?template=backend.md` or other template name to the URL
> - 或者直接复制粘贴对应模板的内容
> - Or simply copy and paste the content from the corresponding template > - Or simply copy and paste the content from the corresponding template
--- ---
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
<!-- Describe your changes in detail -->
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 📝 Documentation update | 文档更新 - [ ] 📝 Documentation update
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] ✅ Test update | 测试更新 - [ ] ✅ Test update
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:** <!-- List the specific changes made -->
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tests pass | 测试通过 - [ ] Tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:** <!-- Any additional information or context -->
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,213 +1,177 @@
# PR Templates | PR 模板 # PR Templates
## 📋 模板概述 | Template Overview ## 📋 Template Overview
我们提供了4种针对不同类型PR的专用模板帮助贡献者快速填写PR信息
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information: We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
### 1. 🔧 Backend Template | 后端模板 ### 1. 🔧 Backend Template
**文件:** `backend.md` **File:** `backend.md`
**适用于 | Use for:** **Use for:**
- Go代码变更 | Go code changes - Go code changes
- API端点开发 | API endpoint development - API endpoint development
- 交易逻辑实现 | Trading logic implementation - Trading logic implementation
- 后端性能优化 | Backend performance optimization - Backend performance optimization
- 数据库相关改动 | Database-related changes - Database-related changes
**包含 | Includes:** **Includes:**
- Go测试环境配置 | Go test environment - Go test environment
- 安全考虑检查 | Security considerations - Security considerations
- 性能影响评估 | Performance impact assessment - Performance impact assessment
- `go fmt``go build` 检查 | `go fmt` and `go build` checks - `go fmt` and `go build` checks
### 2. 🎨 Frontend Template | 前端模板 ### 2. 🎨 Frontend Template
**文件:** `frontend.md` **File:** `frontend.md`
**适用于 | Use for:** **Use for:**
- UI/UX变更 | UI/UX changes - UI/UX changes
- React/Vue组件开发 | React/Vue component development - React/Vue component development
- 前端样式更新 | Frontend styling updates - Frontend styling updates
- 浏览器兼容性修复 | Browser compatibility fixes - Browser compatibility fixes
- 前端性能优化 | Frontend performance optimization - Frontend performance optimization
**包含 | Includes:** **Includes:**
- 截图/演示要求 | Screenshots/demo requirements - Screenshots/demo requirements
- 浏览器测试清单 | Browser testing checklist - Browser testing checklist
- 国际化检查 | Internationalization checks - Internationalization checks
- 响应式设计验证 | Responsive design verification - Responsive design verification
- `npm run lint` `npm run build` 检查 | Linting and build checks - `npm run lint` and `npm run build` checks
### 3. 📝 Documentation Template | 文档模板 ### 3. 📝 Documentation Template
**文件:** `docs.md` **File:** `docs.md`
**适用于 | Use for:** **Use for:**
- README更新 | README updates - README updates
- API文档编写 | API documentation - API documentation
- 教程和指南 | Tutorials and guides - Tutorials and guides
- 代码注释改进 | Code comment improvements - Code comment improvements
- 翻译工作 | Translation work - Translation work
**包含 | Includes:** **Includes:**
- 文档类型分类 | Documentation type classification - Documentation type classification
- 内容质量检查 | Content quality checks - Content quality checks
- 双语要求(中英文)| Bilingual requirements (EN/CN) - Bilingual requirements (EN/CN)
- 链接有效性验证 | Link validity verification - Link validity verification
### 4. 📦 General Template | 通用模板 ### 4. 📦 General Template
**文件:** `general.md` **File:** `general.md`
**适用于 | Use for:** **Use for:**
- 混合类型变更 | Mixed-type changes - Mixed-type changes
- 跨多个领域的PR | Cross-domain PRs - Cross-domain PRs
- 构建配置变更 | Build configuration changes - Build configuration changes
- 依赖更新 | Dependency updates - Dependency updates
- 不确定使用哪个模板时 | When unsure which template to use - When unsure which template to use
## 🤖 自动模板建议 | Automatic Template Suggestion ## 🤖 Automatic Template Suggestion
我们的GitHub Action会自动分析你的PR并建议最合适的模板
Our GitHub Action automatically analyzes your PR and suggests the most suitable template: Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
### 工作原理 | How it works: ### How it works:
1. **文件分析 | File Analysis** 1. **File Analysis**
- 检测PR中所有变更的文件类型
- Detects all changed file types in the PR - Detects all changed file types in the PR
2. **智能判断 | Smart Detection** 2. **Smart Detection**
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
- If >50% are `.go` files → Suggests **Backend template** - If >50% are `.go` files → Suggests **Backend template**
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template** - If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
- If >70% are `.md` files → Suggests **Documentation template** - If >70% are `.md` files → Suggests **Documentation template**
3. **自动评论 | Auto-comment** 3. **Auto-comment**
- 如果检测到你使用了默认模板,但应该用专用模板
- If it detects you're using the default template but should use a specialized one - If it detects you're using the default template but should use a specialized one
- 会自动添加友好的评论建议
- It will automatically add a friendly comment suggestion - It will automatically add a friendly comment suggestion
4. **自动标签 | Auto-labeling** 4. **Auto-labeling**
- 自动添加对应的标签:`backend``frontend``documentation`
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation` - Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
## 📖 使用方法 | How to Use ## 📖 How to Use
### 方法1: URL参数推荐 | Method 1: URL Parameter (Recommended) ### Method 1: URL Parameter (Recommended)
创建PR时在URL末尾添加模板参数
When creating a PR, add the template parameter to the URL: When creating a PR, add the template parameter to the URL:
``` ```
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
``` ```
替换 `backend.md` 为:
Replace `backend.md` with: Replace `backend.md` with:
- `backend.md` - 后端模板 | Backend template - `backend.md` - Backend template
- `frontend.md` - 前端模板 | Frontend template - `frontend.md` - Frontend template
- `docs.md` - 文档模板 | Documentation template - `docs.md` - Documentation template
- `general.md` - 通用模板 | General template - `general.md` - General template
### 方法2: 手动选择 | Method 2: Manual Selection ### Method 2: Manual Selection
1. 创建PR时默认模板会显示 1. When creating a PR, the default template will be shown
When creating a PR, the default template will be shown
2. 根据顶部的指引链接,点击查看对应的模板 2. Follow the guidance links at the top to view the corresponding template
Follow the guidance links at the top to view the corresponding template
3. 复制模板内容到PR描述中 3. Copy the template content into the PR description
Copy the template content into the PR description
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion ### Method 3: Follow Auto-suggestion
1. 使用任何模板创建PR 1. Create a PR with any template
Create a PR with any template
2. GitHub Action会自动分析并评论建议 2. GitHub Action will automatically analyze and comment with a suggestion
GitHub Action will automatically analyze and comment with a suggestion
3. 根据建议更新PR描述 3. Update the PR description based on the suggestion
Update the PR description based on the suggestion
## 🎯 最佳实践 | Best Practices ## 🎯 Best Practices
1. **提前选择 | Choose in Advance** 1. **Choose in Advance**
- 在创建PR前确定变更类型
- Determine the change type before creating the PR - Determine the change type before creating the PR
2. **完整填写 | Complete Filling** 2. **Complete Filling**
- 不要跳过必填项(标记为 required
- Don't skip required items - Don't skip required items
3. **保持简洁 | Keep it Concise** 3. **Keep it Concise**
- 描述清晰但简洁
- Keep descriptions clear but concise - Keep descriptions clear but concise
4. **添加截图 | Add Screenshots** 4. **Add Screenshots**
- 对于UI变更务必添加截图
- For UI changes, always add screenshots - For UI changes, always add screenshots
5. **测试证明 | Test Evidence** 5. **Test Evidence**
- 提供测试通过的证据
- Provide evidence that tests pass - Provide evidence that tests pass
## 🔧 自定义 | Customization ## 🔧 Customization
如果需要修改模板或自动检测逻辑:
If you need to modify templates or auto-detection logic: If you need to modify templates or auto-detection logic:
1. **修改模板** | **Modify Templates** 1. **Modify Templates**
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files - Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
2. **调整检测阈值** | **Adjust Detection Threshold** 2. **Adjust Detection Threshold**
- 编辑 `.github/workflows/pr-template-suggester.yml`
- Edit `.github/workflows/pr-template-suggester.yml` - Edit `.github/workflows/pr-template-suggester.yml`
- 修改文件类型占比阈值当前50%后端50%前端70%文档)
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs) - Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
3. **添加新模板** | **Add New Template** 3. **Add New Template**
-`PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory - Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
- 更新工作流以支持新的文件类型检测
- Update the workflow to support new file type detection - Update the workflow to support new file type detection
## ❓ FAQ ## ❓ FAQ
**Q: 我的PR既有前端又有后端代码用哪个模板**
**Q: My PR has both frontend and backend code, which template should I use?** **Q: My PR has both frontend and backend code, which template should I use?**
A: 使用**通用模板**`general.md`),或选择主要变更类型的模板。
A: Use the **General template** (`general.md`), or choose the template for the primary change type. A: Use the **General template** (`general.md`), or choose the template for the primary change type.
--- ---
**Q: 自动建议的模板不合适怎么办?**
**Q: What if the automatically suggested template is not suitable?** **Q: What if the automatically suggested template is not suitable?**
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only. A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
--- ---
**Q: 可以不使用任何模板吗?**
**Q: Can I not use any template?** **Q: Can I not use any template?**
A: 不推荐。模板帮助确保PR包含必要信息加快审查速度。
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews. A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
--- ---
**Q: 如何禁用自动模板建议?**
**Q: How to disable automatic template suggestions?** **Q: How to disable automatic template suggestions?**
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file. A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
--- ---
🌟 **感谢使用我们的PR模板系统| Thank you for using our PR template system!** 🌟 **Thank you for using our PR template system!**

View File

@@ -1,121 +1,116 @@
# Pull Request - Backend | 后端 PR # Pull Request - Backend
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
### Test Environment | 测试环境 ### Test Environment
- **OS | 操作系统:** - **OS:**
- **Go Version | Go 版本:** - **Go Version:**
- **Exchange | 交易所:** [if applicable | 如适用] - **Exchange:** [if applicable]
### Manual Testing | 手动测试 ### Manual Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关) - [ ] Tested on testnet (for exchange integration)
- [ ] Unit tests pass | 单元测试通过 - [ ] Unit tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
### Test Results | 测试结果 ### Test Results
``` ```
Test output here | 测试输出 Test output here
``` ```
--- ---
## 🔒 Security Considerations | 安全考虑 ## 🔒 Security Considerations
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥 - [ ] No API keys or secrets hardcoded
- [ ] User inputs properly validated | 用户输入已正确验证 - [ ] User inputs properly validated
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞 - [ ] No SQL injection vulnerabilities
- [ ] Authentication/authorization properly handled | 认证/授权正确处理 - [ ] Authentication/authorization properly handled
- [ ] Sensitive data is encrypted | 敏感数据已加密 - [ ] Sensitive data is encrypted
- [ ] N/A (not security-related) | 不适用 - [ ] N/A (not security-related)
--- ---
## ⚡ Performance Impact | 性能影响 ## ⚡ Performance Impact
- [ ] No significant performance impact | 无显著性能影响 - [ ] No significant performance impact
- [ ] Performance improved | 性能提升 - [ ] Performance improved
- [ ] Performance may be impacted (explain below) | 性能可能受影响 - [ ] Performance may be impacted (explain below)
**If impacted, explain | 如果受影响,请说明:** **If impacted, explain:**
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] Code compiles successfully | 代码编译成功 (`go build`) - [ ] Code compiles successfully (`go build`)
- [ ] Ran `go fmt` | 已运行 `go fmt` - [ ] Ran `go fmt`
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Added inline comments where necessary | 已添加必要的代码注释 - [ ] Added inline comments where necessary
- [ ] Updated API documentation (if applicable) | 已更新 API 文档 - [ ] Updated API documentation (if applicable)
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,97 +1,91 @@
# Pull Request - Documentation | 文档 PR # Pull Request - Documentation
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description` > **💡 Tip:** Recommended PR title format `docs(scope): description`
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide` > Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 📚 Type of Documentation | 文档类型 ## 📚 Type of Documentation
- [ ] 📖 README update | README 更新 - [ ] 📖 README update
- [ ] 📋 API documentation | API 文档 - [ ] 📋 API documentation
- [ ] 🎓 Tutorial/Guide | 教程/指南 - [ ] 🎓 Tutorial/Guide
- [ ] 📝 Code comments | 代码注释 - [ ] 📝 Code comments
- [ ] 🔧 Configuration docs | 配置文档 - [ ] 🔧 Configuration docs
- [ ] 🐛 Fix typo/error | 修复拼写/错误 - [ ] 🐛 Fix typo/error
- [ ] 🌍 Translation | 翻译 - [ ] 🌍 Translation
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 📸 Screenshots (if applicable) | 截图(如适用) ## 📸 Screenshots (if applicable)
<!-- For documentation with images, diagrams, or UI examples --> <!-- For documentation with images, diagrams, or UI examples -->
<!-- 用于包含图片、图表或 UI 示例的文档 -->
--- ---
## 🌐 Internationalization | 国际化 ## 🌐 Internationalization
- [ ] English version complete | 英文版本完整 - [ ] English version complete
- [ ] Chinese version complete | 中文版本完整 - [ ] Chinese version complete
- [ ] Both versions are consistent | 两个版本内容一致 - [ ] Both versions are consistent
- [ ] N/A (only one language needed) | 不适用(只需要一种语言) - [ ] N/A (only one language needed)
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Content Quality | 内容质量 ### Content Quality
- [ ] Information is accurate and up-to-date | 信息准确且最新 - [ ] Information is accurate and up-to-date
- [ ] Language is clear and concise | 语言清晰简洁 - [ ] Language is clear and concise
- [ ] No spelling or grammar errors | 无拼写或语法错误 - [ ] No spelling or grammar errors
- [ ] Links are valid and working | 链接有效且可用 - [ ] Links are valid and working
- [ ] Code examples are tested and working | 代码示例已测试且可用 - [ ] Code examples are tested and working
- [ ] Formatting is consistent | 格式一致 - [ ] Formatting is consistent
### Documentation Standards | 文档标准 ### Documentation Standards
- [ ] Follows project documentation style | 遵循项目文档风格 - [ ] Follows project documentation style
- [ ] Includes necessary examples | 包含必要的示例 - [ ] Includes necessary examples
- [ ] Technical terms are explained | 技术术语已解释 - [ ] Technical terms are explained
- [ ] Self-review completed | 已完成自查 - [ ] Self-review completed
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,119 +1,113 @@
# Pull Request - Frontend | 前端 PR # Pull Request - Frontend
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug` > Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 📸 Screenshots / Demo | 截图/演示 ## 📸 Screenshots / Demo
<!-- For UI changes, include before/after screenshots or video demo --> <!-- For UI changes, include before/after screenshots or video demo -->
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
**Before | 变更前:** **Before:**
**After | 变更后:** **After:**
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
### Test Environment | 测试环境 ### Test Environment
- **OS | 操作系统:** - **OS:**
- **Node Version | Node 版本:** - **Node Version:**
- **Browser(s) | 浏览器:** - **Browser(s):**
### Manual Testing | 手动测试 ### Manual Testing
- [ ] Tested in development mode | 开发模式测试通过 - [ ] Tested in development mode
- [ ] Tested production build | 生产构建测试通过 - [ ] Tested production build
- [ ] Tested on multiple browsers | 多浏览器测试通过 - [ ] Tested on multiple browsers
- [ ] Tested responsive design | 响应式设计测试通过 - [ ] Tested responsive design
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
--- ---
## 🌐 Internationalization | 国际化 ## 🌐 Internationalization
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化 - [ ] All user-facing text supports i18n
- [ ] Both English and Chinese versions provided | 提供了中英文版本 - [ ] Both English and Chinese versions provided
- [ ] N/A | 不适用 - [ ] N/A
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] Code builds successfully | 代码构建成功 (`npm run build`) - [ ] Code builds successfully (`npm run build`)
- [ ] Ran `npm run lint` | 已运行 `npm run lint` - [ ] Ran `npm run lint`
- [ ] No console errors or warnings | 无控制台错误或警告 - [ ] No console errors or warnings
### Testing | 测试 ### Testing
- [ ] Component tests added/updated | 已添加/更新组件测试 - [ ] Component tests added/updated
- [ ] Tests pass locally | 测试在本地通过 - [ ] Tests pass locally
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Updated type definitions (TypeScript) | 已更新类型定义 - [ ] Updated type definitions (TypeScript)
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释 - [ ] Added JSDoc comments where necessary
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,98 +1,93 @@
# Pull Request - General | 通用 PR # Pull Request - General
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 📝 Documentation update | 文档更新 - [ ] 📝 Documentation update
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] ✅ Test update | 测试更新 - [ ] ✅ Test update
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tests pass | 测试通过 - [ ] Tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
**Test details | 测试详情:** **Test details:**
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] No new warnings or errors | 无新的警告或错误 - [ ] No new warnings or errors
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Added inline comments where necessary | 已添加必要的代码注释 - [ ] Added inline comments where necessary
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 🔒 Security (if applicable) | 安全(如适用) ## 🔒 Security (if applicable)
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥 - [ ] No API keys or secrets hardcoded
- [ ] User inputs properly validated | 用户输入已正确验证 - [ ] User inputs properly validated
- [ ] N/A | 不适用 - [ ] N/A
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

40
Dockerfile.railway Normal file
View File

@@ -0,0 +1,40 @@
# Railway All-in-One: 复用现有 GHCR 镜像
# 从现有镜像提取内容,合并到一个容器
# 从后端镜像提取二进制
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
# 从前端镜像提取静态文件
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
# 最终镜像
FROM alpine:latest
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
# 复制后端二进制
COPY --from=backend /app/nofx /app/nofx
# 复制 TA-Lib 库
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
RUN ldconfig /usr/local/lib 2>/dev/null || true
# 复制前端静态文件
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
WORKDIR /app
RUN mkdir -p /app/data
# 启动脚本(包含 nginx 配置生成)
COPY railway/start.sh /app/start.sh
RUN chmod +x /app/start.sh
ENV DB_PATH=/app/data/data.db
# Railway 会自动设置 PORT 环境变量
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8080}/health || exit 1
CMD ["/app/start.sh"]

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バトル ### 🏆 競争モード - リアルタイムAIバトル

145
README.md
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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>The infrastructure layer for AI-powered financial trading.</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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 | | 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 ### Supported Markets
| Market | Trading | Status | | Market | Trading | Status |
@@ -30,7 +38,7 @@
### Core Features ### Core Features
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime - **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 - **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 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 - **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
@@ -42,6 +50,12 @@
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official) - **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
### Official Links
- **Official Website**: [https://nofxai.com](https://nofxai.com)
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only! > **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
## Developer Community ## Developer Community
@@ -50,6 +64,52 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
--- ---
## Before You Begin
To use NOFX, you'll need:
1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
---
## Supported Exchanges
### CEX (Centralized Exchanges)
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <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) |
|:---------|:------:|:------------------------|
| <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 |
|:---------|:------:|:------------|
| <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) |
---
## Screenshots ## Screenshots
### Config Page ### Config Page
@@ -87,44 +147,9 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
--- ---
## Supported Exchanges
### 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) |
### 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) |
---
## 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) |
---
## Quick Start ## Quick Start
### One-Click Install (Recommended) ### One-Click Install (Local/Server)
**Linux / macOS:** **Linux / macOS:**
```bash ```bash
@@ -133,6 +158,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
That's it! Open **http://127.0.0.1:3000** in your browser. That's it! Open **http://127.0.0.1:3000** in your browser.
### One-Click Cloud Deploy (Railway)
Deploy to Railway with one click - no server setup required:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
After deployment, Railway will provide a public URL to access your NOFX instance.
### Docker Compose (Manual) ### Docker Compose (Manual)
```bash ```bash
@@ -465,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
[![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date) [![Star History Chart](https://api.star-history.com/svg?repos=NoFxAiOS/nofx&type=Date)](https://star-history.com/#NoFxAiOS/nofx&Date)

View File

@@ -1,252 +0,0 @@
package api
import (
"testing"
)
// MockUser Mock user structure
type MockUser struct {
ID int
Email string
OTPSecret string
OTPVerified bool
}
// TestOTPRefetchLogic Test OTP refetch logic
func TestOTPRefetchLogic(t *testing.T) {
tests := []struct {
name string
existingUser *MockUser
userExists bool
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
expectedMessage string
}{
{
name: "New user registration - email does not exist",
existingUser: nil,
userExists: false,
expectedAction: "create_new",
expectedMessage: "Create new user",
},
{
name: "Incomplete OTP verification - allow refetch",
existingUser: &MockUser{
ID: 1,
Email: "test@example.com",
OTPSecret: "SECRET123",
OTPVerified: false,
},
userExists: true,
expectedAction: "allow_refetch",
expectedMessage: "Incomplete registration detected, please continue OTP setup",
},
{
name: "Completed OTP verification - reject duplicate registration",
existingUser: &MockUser{
ID: 2,
Email: "verified@example.com",
OTPSecret: "SECRET456",
OTPVerified: true,
},
userExists: true,
expectedAction: "reject_duplicate",
expectedMessage: "Email already registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate logic processing flow
var actualAction string
var actualMessage string
if !tt.userExists {
// User does not exist, create new user
actualAction = "create_new"
actualMessage = "Create new user"
} else {
// User exists, check OTP verification status
if !tt.existingUser.OTPVerified {
// OTP verification incomplete, allow refetch
actualAction = "allow_refetch"
actualMessage = "Incomplete registration detected, please continue OTP setup"
} else {
// Verification completed, reject duplicate registration
actualAction = "reject_duplicate"
actualMessage = "Email already registered"
}
}
// Verify results
if actualAction != tt.expectedAction {
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
}
if actualMessage != tt.expectedMessage {
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
}
})
}
}
// TestOTPVerificationStates Test OTP verification state determination
func TestOTPVerificationStates(t *testing.T) {
tests := []struct {
name string
otpVerified bool
shouldAllowRefetch bool
}{
{
name: "OTP verified - disallow refetch",
otpVerified: true,
shouldAllowRefetch: false,
},
{
name: "OTP not verified - allow refetch",
otpVerified: false,
shouldAllowRefetch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate verification logic
allowRefetch := !tt.otpVerified
if allowRefetch != tt.shouldAllowRefetch {
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
}
})
}
}
// TestRegistrationFlow Test complete registration flow logic branches
func TestRegistrationFlow(t *testing.T) {
tests := []struct {
name string
scenario string
userExists bool
otpVerified bool
expectHTTPCode int // Simulated HTTP status code
expectResponse string
}{
{
name: "Scenario 1: New user first registration",
scenario: "New user first accesses registration endpoint",
userExists: false,
otpVerified: false,
expectHTTPCode: 200,
expectResponse: "Create user and return OTP setup information",
},
{
name: "Scenario 2: User re-accesses after interrupting registration",
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
userExists: true,
otpVerified: false,
expectHTTPCode: 200,
expectResponse: "Return existing user's OTP information, allow continuation",
},
{
name: "Scenario 3: Registered user attempts duplicate registration",
scenario: "User already completed registration, attempts to register again with same email",
userExists: true,
otpVerified: true,
expectHTTPCode: 409, // Conflict
expectResponse: "Email already registered",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate registration flow logic
var actualHTTPCode int
var actualResponse string
if !tt.userExists {
// New user, create and return OTP information
actualHTTPCode = 200
actualResponse = "Create user and return OTP setup information"
} else {
// User exists
if !tt.otpVerified {
// OTP verification incomplete, allow refetch
actualHTTPCode = 200
actualResponse = "Return existing user's OTP information, allow continuation"
} else {
// Verification completed, reject duplicate registration
actualHTTPCode = 409
actualResponse = "Email already registered"
}
}
// Verify
if actualHTTPCode != tt.expectHTTPCode {
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
}
if actualResponse != tt.expectResponse {
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
actualResponse, tt.expectResponse, tt.scenario)
}
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
})
}
}
// TestEdgeCases Test edge cases
func TestEdgeCases(t *testing.T) {
tests := []struct {
name string
user *MockUser
expectAllow bool
description string
}{
{
name: "User ID is 0 - treated as new user",
user: &MockUser{
ID: 0,
Email: "new@example.com",
OTPVerified: false,
},
expectAllow: true,
description: "ID of 0 usually indicates user has not been created yet",
},
{
name: "OTPSecret is empty - still can refetch",
user: &MockUser{
ID: 1,
Email: "test@example.com",
OTPSecret: "",
OTPVerified: false,
},
expectAllow: true,
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
},
{
name: "OTPSecret exists but already verified - not allowed",
user: &MockUser{
ID: 2,
Email: "verified@example.com",
OTPSecret: "SECRET789",
OTPVerified: true,
},
expectAllow: false,
description: "Users with verified OTP cannot refetch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Core logic: as long as OTPVerified is false, refetch is allowed
allowRefetch := !tt.user.OTPVerified
if allowRefetch != tt.expectAllow {
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
}
t.Logf("✓ %s", tt.description)
})
}
}

View File

@@ -20,6 +20,15 @@ import (
"nofx/provider/twelvedata" "nofx/provider/twelvedata"
"nofx/store" "nofx/store"
"nofx/trader" "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" "strconv"
"strings" "strings"
"time" "time"
@@ -133,8 +142,6 @@ func (s *Server) setupRoutes() {
// Authentication related routes (no authentication required) // Authentication related routes (no authentication required)
api.POST("/register", s.handleRegister) api.POST("/register", s.handleRegister)
api.POST("/login", s.handleLogin) api.POST("/login", s.handleLogin)
api.POST("/verify-otp", s.handleVerifyOTP)
api.POST("/complete-registration", s.handleCompleteRegistration)
// Routes requiring authentication // Routes requiring authentication
protected := api.Group("/", s.authMiddleware()) protected := api.Group("/", s.authMiddleware())
@@ -157,6 +164,7 @@ func (s *Server) setupRoutes() {
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
protected.POST("/traders/:id/close-position", s.handleClosePosition) protected.POST("/traders/:id/close-position", s.handleClosePosition)
protected.PUT("/traders/:id/competition", s.handleToggleCompetition) protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
// AI model configuration // AI model configuration
protected.GET("/models", s.handleGetModelConfigs) protected.GET("/models", s.handleGetModelConfigs)
@@ -202,6 +210,7 @@ func (s *Server) setupRoutes() {
protected.GET("/trades", s.handleTrades) protected.GET("/trades", s.handleTrades)
protected.GET("/orders", s.handleOrders) // Order list (all orders) protected.GET("/orders", s.handleOrders) // Order list (all orders)
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
protected.GET("/decisions", s.handleDecisions) protected.GET("/decisions", s.handleDecisions)
protected.GET("/decisions/latest", s.handleLatestDecisions) protected.GET("/decisions/latest", s.handleLatestDecisions)
protected.GET("/statistics", s.handleStatistics) protected.GET("/statistics", s.handleStatistics)
@@ -254,13 +263,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 { func getPublicIPFromAPI() string {
// Try multiple public IP query services // Try multiple public IP query services (IPv4-only endpoints)
services := []string{ services := []string{
"https://api.ipify.org?format=text", "https://api4.ipify.org?format=text", // IPv4 only
"https://icanhazip.com", "https://ipv4.icanhazip.com", // IPv4 only
"https://ifconfig.me", "https://v4.ident.me", // IPv4 only
"https://api.ipify.org?format=text", // May return IPv4 or IPv6
} }
client := &http.Client{ client := &http.Client{
@@ -282,8 +292,9 @@ func getPublicIPFromAPI() string {
} }
ip := strings.TrimSpace(string(body[:n])) ip := strings.TrimSpace(string(body[:n]))
// Verify if it's a valid IP address parsedIP := net.ParseIP(ip)
if net.ParseIP(ip) != nil { // Verify if it's a valid IPv4 address (not containing ":")
if parsedIP != nil && parsedIP.To4() != nil {
return ip return ip
} }
} }
@@ -471,6 +482,7 @@ type UpdateExchangeConfigRequest struct {
Passphrase string `json:"passphrase"` // OKX specific Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"` Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
AsterUser string `json:"aster_user"` AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"` AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"` AsterPrivateKey string `json:"aster_private_key"`
@@ -581,32 +593,44 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader( tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), // private key string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = trader.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = trader.NewBybitTrader( tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = trader.NewOKXTrader( tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": 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.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -614,7 +638,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2( tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1095,6 +1119,20 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
}) })
} }
// handleGetGridRiskInfo returns current risk information for a grid trader
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
traderID := c.Param("id")
autoTrader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
return
}
riskInfo := autoTrader.GetGridRiskInfo()
c.JSON(http.StatusOK, riskInfo)
}
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection) // handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
func (s *Server) handleSyncBalance(c *gin.Context) { func (s *Server) handleSyncBalance(c *gin.Context) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
@@ -1125,32 +1163,44 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader( tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = trader.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = trader.NewBybitTrader( tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = trader.NewOKXTrader( tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": 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.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -1158,7 +1208,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2( tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1277,32 +1327,44 @@ func (s *Server) handleClosePosition(c *gin.Context) {
// Convert EncryptedString fields to string // Convert EncryptedString fields to string
switch exchangeCfg.ExchangeType { switch exchangeCfg.ExchangeType {
case "binance": case "binance":
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
case "hyperliquid": case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader( tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = trader.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
exchangeCfg.AsterUser, exchangeCfg.AsterUser,
exchangeCfg.AsterSigner, exchangeCfg.AsterSigner,
string(exchangeCfg.AsterPrivateKey), string(exchangeCfg.AsterPrivateKey),
) )
case "bybit": case "bybit":
tempTrader = trader.NewBybitTrader( tempTrader = bybit.NewBybitTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
) )
case "okx": case "okx":
tempTrader = trader.NewOKXTrader( tempTrader = okx.NewOKXTrader(
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
) )
case "bitget": 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.APIKey),
string(exchangeCfg.SecretKey), string(exchangeCfg.SecretKey),
string(exchangeCfg.Passphrase), string(exchangeCfg.Passphrase),
@@ -1310,7 +1372,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
case "lighter": case "lighter":
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
// Lighter only supports mainnet // Lighter only supports mainnet
tempTrader, createErr = trader.NewLighterTraderV2( tempTrader, createErr = lighter.NewLighterTraderV2(
exchangeCfg.LighterWalletAddr, exchangeCfg.LighterWalletAddr,
string(exchangeCfg.LighterAPIKeyPrivateKey), string(exchangeCfg.LighterAPIKeyPrivateKey),
exchangeCfg.LighterAPIKeyIndex, exchangeCfg.LighterAPIKeyIndex,
@@ -1368,7 +1430,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
if closeErr != nil { if closeErr != nil {
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr) logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
SafeInternalError(c, "Failed to close position", closeErr) SafeInternalError(c, "Close position", closeErr)
return return
} }
@@ -1389,7 +1451,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{}) { 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 // Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType { 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") logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return return
} }
@@ -1452,9 +1514,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
FilledQuantity: quantity, FilledQuantity: quantity,
AvgFillPrice: exitPrice, AvgFillPrice: exitPrice,
Commission: fee, Commission: fee,
FilledAt: time.Now(), FilledAt: time.Now().UTC().UnixMilli(),
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now(), UpdatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateOrder(orderRecord); err != nil { if err := s.store.Order().CreateOrder(orderRecord); err != nil {
@@ -1482,7 +1544,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, RealizedPnL: 0,
IsMaker: false, IsMaker: false,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateFill(fillRecord); err != nil { if err := s.store.Order().CreateFill(fillRecord); err != nil {
@@ -1557,7 +1619,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, RealizedPnL: 0,
IsMaker: false, IsMaker: false,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateFill(fillRecord); err != nil { if err := s.store.Order().CreateFill(fillRecord); err != nil {
@@ -1704,8 +1766,15 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID) logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
} }
// Update each model's configuration // Update each model's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for modelID, modelData := range req.Models { for modelID, modelData := range req.Models {
// Find traders using this AI model BEFORE updating
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
for _, t := range traders {
tradersToReload[t.ID] = true
}
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
if err != nil { if err != nil {
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err) SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
@@ -1713,6 +1782,12 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
} }
} }
// Remove affected traders from memory BEFORE reloading to pick up new config
for traderID := range tradersToReload {
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Reload all traders for this user to make new config take effect immediately // Reload all traders for this user to make new config take effect immediately
err = s.traderManager.LoadUserTradersFromStore(s.store, userID) err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil { if err != nil {
@@ -1824,15 +1899,28 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID) logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
} }
// Update each exchange's configuration // Update each exchange's configuration and track traders that need reload
tradersToReload := make(map[string]bool)
for exchangeID, exchangeData := range req.Exchanges { for exchangeID, exchangeData := range req.Exchanges {
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) // Find traders using this exchange BEFORE updating
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
for _, t := range traders {
tradersToReload[t.ID] = true
}
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil { if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err) SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return return
} }
} }
// Remove affected traders from memory BEFORE reloading to pick up new config
for traderID := range tradersToReload {
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
s.traderManager.RemoveTrader(traderID)
}
// Reload all traders for this user to make new config take effect immediately // Reload all traders for this user to make new config take effect immediately
err = s.traderManager.LoadUserTradersFromStore(s.store, userID) err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
if err != nil { if err != nil {
@@ -1854,6 +1942,7 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"` Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"` Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
AsterUser string `json:"aster_user"` AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"` AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"` AsterPrivateKey string `json:"aster_private_key"`
@@ -1917,7 +2006,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
// Validate exchange type // Validate exchange type
validTypes := map[string]bool{ validTypes := map[string]bool{
"binance": true, "bybit": true, "okx": true, "bitget": true, "binance": true, "bybit": true, "okx": true, "bitget": true,
"hyperliquid": true, "aster": true, "lighter": true, "hyperliquid": true, "aster": true, "lighter": true, "gate": true, "kucoin": true, "indodax": true,
} }
if !validTypes[req.ExchangeType] { if !validTypes[req.ExchangeType] {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
@@ -1928,7 +2017,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
id, err := s.store.Exchange().Create( id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled, userID, req.ExchangeType, req.AccountName, req.Enabled,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet, req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey, req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex, req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
) )
if err != nil { if err != nil {
@@ -2294,28 +2384,14 @@ func (s *Server) handleOrders(c *gin.Context) {
return return
} }
// Get all orders for this trader // Get orders with filters applied at database level
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit) orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
if err != nil { if err != nil {
SafeInternalError(c, "Get orders", err) SafeInternalError(c, "Get orders", err)
return return
} }
// Filter by symbol and status if specified c.JSON(http.StatusOK, orders)
result := make([]interface{}, 0)
for _, order := range allOrders {
// Filter by symbol
if symbol != "" && order.Symbol != symbol {
continue
}
// Filter by status
if statusFilter != "" && order.Status != statusFilter {
continue
}
result = append(result, order)
}
c.JSON(http.StatusOK, result)
} }
// handleOrderFills Order fill details (all fills for a specific order) // handleOrderFills Order fill details (all fills for a specific order)
@@ -2355,6 +2431,40 @@ func (s *Server) handleOrderFills(c *gin.Context) {
c.JSON(http.StatusOK, fills) c.JSON(http.StatusOK, fills)
} }
// handleOpenOrders Get open orders (pending SL/TP) from exchange
func (s *Server) handleOpenOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get symbol parameter (required for exchange query)
symbol := c.Query("symbol")
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
return
}
// Normalize symbol
symbol = market.Normalize(symbol)
// Get open orders from exchange
openOrders, err := trader.GetOpenOrders(symbol)
if err != nil {
SafeInternalError(c, "Get open orders", err)
return
}
c.JSON(http.StatusOK, openOrders)
}
// handleKlines K-line data (supports multiple exchanges via coinank) // handleKlines K-line data (supports multiple exchanges via coinank)
func (s *Server) handleKlines(c *gin.Context) { func (s *Server) handleKlines(c *gin.Context) {
// Get query parameters // Get query parameters
@@ -2429,11 +2539,16 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
coinankExchange = coinank_enum.Okex coinankExchange = coinank_enum.Okex
case "bitget": case "bitget":
coinankExchange = coinank_enum.Bitget coinankExchange = coinank_enum.Bitget
case "gate":
coinankExchange = coinank_enum.Gate
case "aster": case "aster":
coinankExchange = coinank_enum.Aster coinankExchange = coinank_enum.Aster
case "lighter": case "lighter":
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback // Lighter doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance coinankExchange = coinank_enum.Binance
case "kucoin":
// KuCoin doesn't have direct CoinAnk support, use Binance data as fallback
coinankExchange = coinank_enum.Binance
default: default:
// For any unknown exchange, default to Binance // For any unknown exchange, default to Binance
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange) logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
@@ -2968,20 +3083,6 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Check max users limit
maxUsers := config.Get().MaxUsers
if maxUsers > 0 {
userCount, err := s.store.User().Count()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
return
}
if userCount >= maxUsers {
c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"})
return
}
}
var req struct { var req struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`
@@ -2999,6 +3100,20 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Check max users limit (only for new users)
maxUsers := config.Get().MaxUsers
if maxUsers > 0 {
userCount, err := s.store.User().Count()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
return
}
if userCount >= maxUsers {
c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"})
return
}
}
// Generate password hash // Generate password hash
passwordHash, err := auth.HashPassword(req.Password) passwordHash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
@@ -3006,21 +3121,12 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Generate OTP secret // Create user
otpSecret, err := auth.GenerateOTPSecret()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP secret generation failed"})
return
}
// Create user (unverified OTP status)
userID := uuid.New().String() userID := uuid.New().String()
user := &store.User{ user := &store.User{
ID: userID, ID: userID,
Email: req.Email, Email: req.Email,
PasswordHash: passwordHash, PasswordHash: passwordHash,
OTPSecret: otpSecret,
OTPVerified: false,
} }
err = s.store.User().Create(user) err = s.store.User().Create(user)
@@ -3029,49 +3135,6 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Return OTP setup information
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"email": req.Email,
"otp_secret": otpSecret,
"qr_code_url": qrCodeURL,
"message": "Please scan the QR code with Google Authenticator and verify OTP",
})
}
// handleCompleteRegistration Complete registration (verify OTP)
func (s *Server) handleCompleteRegistration(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByID(req.UserID)
if err != nil {
SafeNotFound(c, "User")
return
}
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "OTP code error"})
return
}
// Update user OTP verified status
err = s.store.User().UpdateOTPVerified(req.UserID, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
return
}
// Generate JWT token // Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email) token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil { if err != nil {
@@ -3089,7 +3152,7 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
"token": token, "token": token,
"user_id": user.ID, "user_id": user.ID,
"email": user.Email, "email": user.Email,
"message": "Registration completed", "message": "Registration successful",
}) })
} }
@@ -3118,51 +3181,7 @@ func (s *Server) handleLogin(c *gin.Context) {
return return
} }
// Check if OTP is verified // Issue token directly after password verification.
if !user.OTPVerified {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Account has not completed OTP setup",
"user_id": user.ID,
"requires_otp_setup": true,
})
return
}
// Return status requiring OTP verification
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"email": user.Email,
"message": "Please enter Google Authenticator code",
"requires_otp": true,
})
}
// handleVerifyOTP Verify OTP and complete login
func (s *Server) handleVerifyOTP(c *gin.Context) {
var req struct {
UserID string `json:"user_id" binding:"required"`
OTPCode string `json:"otp_code" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Get user information
user, err := s.store.User().GetByID(req.UserID)
if err != nil {
SafeNotFound(c, "User")
return
}
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Verification code error"})
return
}
// Generate JWT token
token, err := auth.GenerateJWT(user.ID, user.Email) token, err := auth.GenerateJWT(user.ID, user.Email)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
@@ -3177,12 +3196,11 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
}) })
} }
// handleResetPassword Reset password (via email + OTP verification) // handleResetPassword Reset password via email and new password
func (s *Server) handleResetPassword(c *gin.Context) { func (s *Server) handleResetPassword(c *gin.Context) {
var req struct { var req struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
NewPassword string `json:"new_password" binding:"required,min=6"` NewPassword string `json:"new_password" binding:"required,min=6"`
OTPCode string `json:"otp_code" binding:"required"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -3197,12 +3215,6 @@ func (s *Server) handleResetPassword(c *gin.Context) {
return return
} }
// Verify OTP
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Google Authenticator code error"})
return
}
// Generate new password hash // Generate new password hash
newPasswordHash, err := auth.HashPassword(req.NewPassword) newPasswordHash, err := auth.HashPassword(req.NewPassword)
if err != nil { if err != nil {
@@ -3236,7 +3248,7 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"}, {"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"}, {"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"}, {"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-5-20251101"}, {"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"}, {"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"}, {"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"}, {"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
@@ -3253,6 +3265,8 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"}, {ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"}, {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
{ExchangeType: "okx", Name: "OKX 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: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"}, {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"}, {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},

View File

@@ -1,15 +1,12 @@
package auth package auth
import ( import (
"crypto/rand"
"fmt" "fmt"
"log" "log"
"sync" "sync"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
// maxBlacklistEntries is the maximum capacity threshold for blacklist // maxBlacklistEntries is the maximum capacity threshold for blacklist
const maxBlacklistEntries = 100_000 const maxBlacklistEntries = 100_000
// OTPIssuer is the OTP issuer name
const OTPIssuer = "nofxAI"
// SetJWTSecret sets the JWT secret key // SetJWTSecret sets the JWT secret key
func SetJWTSecret(secret string) { func SetJWTSecret(secret string) {
JWTSecret = []byte(secret) JWTSecret = []byte(secret)
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
return err == nil return err == nil
} }
// GenerateOTPSecret generates OTP secret
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: OTPIssuer,
AccountName: uuid.New().String(),
})
if err != nil {
return "", err
}
return key.Secret(), nil
}
// VerifyOTP verifies OTP code
func VerifyOTP(secret, code string) bool {
return totp.Validate(code, secret)
}
// GenerateJWT generates JWT token // GenerateJWT generates JWT token
func GenerateJWT(userID, email string) (string, error) { func GenerateJWT(userID, email string) (string, error) {
claims := Claims{ claims := Claims{
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
return nil, fmt.Errorf("invalid token") return nil, fmt.Errorf("invalid token")
} }
// GetOTPQRCodeURL gets OTP QR code URL
func GetOTPQRCodeURL(secret, email string) string {
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
}

View File

@@ -122,10 +122,10 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
} }
execPrice := applySlippage(price, acc.slippageRate, side, false) execPrice := applySlippage(price, acc.slippageRate, side, false)
notional := execPrice * quantity closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
closingFee := notional * acc.feeRate closingFee := closeNotional * acc.feeRate
// Calculate proportional opening fee for the quantity being closed // Calculate proportional values based on the portion being closed
closePortion := quantity / pos.Quantity closePortion := quantity / pos.Quantity
openingFeePortion := pos.AccumulatedFee * closePortion openingFeePortion := pos.AccumulatedFee * closePortion
totalFee := closingFee + openingFeePortion totalFee := closingFee + openingFeePortion
@@ -133,13 +133,17 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
realized := realizedPnL(pos, quantity, execPrice) realized := realizedPnL(pos, quantity, execPrice)
marginPortion := pos.Margin * closePortion marginPortion := pos.Margin * closePortion
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
entryNotionalPortion := pos.Notional * closePortion
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here // Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
acc.cash += marginPortion + realized - closingFee acc.cash += marginPortion + realized - closingFee
// But for realized P&L tracking, we include both fees // But for realized P&L tracking, we include both fees
acc.realizedPnL += realized - totalFee acc.realizedPnL += realized - totalFee
pos.Quantity -= quantity pos.Quantity -= quantity
pos.Notional -= notional pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
pos.Margin -= marginPortion pos.Margin -= marginPortion
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee

View File

@@ -124,11 +124,23 @@ func (df *DataFeed) DecisionBarCount() int {
} }
func (df *DataFeed) DecisionTimestamp(index int) int64 { func (df *DataFeed) DecisionTimestamp(index int) int64 {
// Bounds check to prevent panic
if index < 0 || index >= len(df.decisionTimes) {
return 0
}
return df.decisionTimes[index] return df.decisionTimes[index]
} }
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline { func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
series := df.symbolSeries[symbol].byTF[tf] // Nil checks to prevent panic
ss, ok := df.symbolSeries[symbol]
if !ok || ss == nil {
return nil
}
series, ok := ss.byTF[tf]
if !ok || series == nil {
return nil
}
idx := sort.Search(len(series.closeTimes), func(i int) bool { idx := sort.Search(len(series.closeTimes), func(i int) bool {
return series.closeTimes[i] > ts return series.closeTimes[i] > ts
}) })

View File

@@ -91,8 +91,13 @@ func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
return maxDD return maxDD
} }
// sharpeRatio calculates the Sharpe ratio from equity points.
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
// Returns math.NaN() for edge cases (insufficient data, zero variance).
func sharpeRatio(points []EquityPoint) float64 { func sharpeRatio(points []EquityPoint) float64 {
if len(points) < 2 { // Need at least 10 data points for meaningful Sharpe calculation
const minDataPoints = 10
if len(points) < minDataPoints {
return 0 return 0
} }
@@ -108,34 +113,42 @@ func sharpeRatio(points []EquityPoint) float64 {
returns = append(returns, ret) returns = append(returns, ret)
prev = curr prev = curr
} }
if len(returns) == 0 { if len(returns) < minDataPoints-1 {
return 0 return 0
} }
// Calculate mean return
mean := 0.0 mean := 0.0
for _, r := range returns { for _, r := range returns {
mean += r mean += r
} }
mean /= float64(len(returns)) mean /= float64(len(returns))
// Calculate sample variance (using n-1 for unbiased estimator)
variance := 0.0 variance := 0.0
for _, r := range returns { for _, r := range returns {
diff := r - mean diff := r - mean
variance += diff * diff variance += diff * diff
} }
variance /= float64(len(returns)) if len(returns) > 1 {
variance /= float64(len(returns) - 1)
}
std := math.Sqrt(variance) std := math.Sqrt(variance)
if std == 0 { if std < 1e-10 {
if mean > 0 { // Zero or near-zero volatility - return 0 instead of infinity/NaN
return 999
}
if mean < 0 {
return -999
}
return 0 return 0
} }
return mean / std
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
// Annualize by multiplying by sqrt(periods per year)
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
// For conservative estimate, use sqrt(252) as if daily returns
periodsPerYear := 252.0
annualizationFactor := math.Sqrt(periodsPerYear)
sharpe := (mean / std) * annualizationFactor
return sharpe
} }
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) { func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
@@ -189,7 +202,8 @@ func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
if totalLossAmount > 0 { if totalLossAmount > 0 {
metrics.ProfitFactor = totalWinAmount / totalLossAmount metrics.ProfitFactor = totalWinAmount / totalLossAmount
} else if totalWinAmount > 0 { } else if totalWinAmount > 0 {
metrics.ProfitFactor = 999 // No losses but have wins - use a high but reasonable cap
metrics.ProfitFactor = 100.0
} }
bestSymbol := "" bestSymbol := ""

View File

@@ -2,15 +2,39 @@ package backtest
import ( import (
"database/sql" "database/sql"
"fmt"
"strings"
) )
var persistenceDB *sql.DB var persistenceDB *sql.DB
var dbIsPostgres bool
// UseDatabase enables database-backed persistence for all backtest storage operations. // UseDatabase enables database-backed persistence for all backtest storage operations.
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
func UseDatabase(db *sql.DB) { func UseDatabase(db *sql.DB) {
persistenceDB = db persistenceDB = db
} }
// UseDatabaseWithType enables database-backed persistence with explicit type.
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
persistenceDB = db
dbIsPostgres = isPostgres
}
func usingDB() bool { func usingDB() bool {
return persistenceDB != nil return persistenceDB != nil
} }
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
func convertQuery(query string) string {
if !dbIsPostgres {
return query
}
result := query
index := 1
for strings.Contains(result, "?") {
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
index++
}
return result
}

View File

@@ -73,12 +73,12 @@ func enforceRetentionDB(maxRuns int) {
RunStateFailed, RunStateFailed,
RunStateLiquidated, RunStateLiquidated,
} }
query := ` query := convertQuery(`
SELECT run_id FROM backtest_runs SELECT run_id FROM backtest_runs
WHERE state IN (?, ?, ?, ?) WHERE state IN (?, ?, ?, ?)
ORDER BY updated_at DESC ORDER BY updated_at DESC
OFFSET ? OFFSET ?
` `)
rows, err := persistenceDB.Query(query, rows, err := persistenceDB.Query(query,
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns) finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
if err != nil { if err != nil {

View File

@@ -60,8 +60,9 @@ type Runner struct {
aiCache *AICache aiCache *AICache
cachePath string cachePath string
lockInfo *RunLockInfo lockInfo *RunLockInfo
lockStop chan struct{} lockStop chan struct{}
lockStopOnce sync.Once // Ensures lockStop is closed only once
} }
// NewRunner constructs a backtest runner. // NewRunner constructs a backtest runner.
@@ -175,10 +176,12 @@ func (r *Runner) lockHeartbeatLoop() {
} }
func (r *Runner) releaseLock() { func (r *Runner) releaseLock() {
if r.lockStop != nil { // Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
close(r.lockStop) r.lockStopOnce.Do(func() {
r.lockStop = nil if r.lockStop != nil {
} close(r.lockStop)
}
})
if err := deleteRunLock(r.cfg.RunID); err != nil { if err := deleteRunLock(r.cfg.RunID); err != nil {
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err) logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
} }
@@ -297,9 +300,12 @@ func (r *Runner) stepOnce() error {
if shouldDecide { if shouldDecide {
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount) ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
if err != nil { if err != nil {
rec.Success = false // Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err) if rec != nil {
_ = r.logDecision(rec) rec.Success = false
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
_ = r.logDecision(rec)
}
return err return err
} }
record = rec record = rec
@@ -617,6 +623,10 @@ func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, e
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) { func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
symbol := dec.Symbol symbol := dec.Symbol
if symbol == "" {
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
}
usedLeverage := r.resolveLeverage(dec.Leverage, symbol) usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
actionRecord := store.DecisionAction{ actionRecord := store.DecisionAction{
Action: dec.Action, Action: dec.Action,
@@ -625,9 +635,13 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6
Timestamp: time.UnixMilli(ts).UTC(), Timestamp: time.UnixMilli(ts).UTC(),
} }
basePrice := priceMap[symbol] if priceMap == nil {
if basePrice <= 0 { return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s", symbol) }
basePrice, ok := priceMap[symbol]
if !ok || basePrice <= 0 {
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
} }
fillPrice := r.executionPrice(symbol, basePrice, ts) fillPrice := r.executionPrice(symbol, basePrice, ts)
@@ -757,6 +771,9 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6
} }
} }
// MinPositionSizeUSD is the minimum position size in USD to avoid dust positions
const MinPositionSizeUSD = 10.0
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 { func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
snapshot := r.snapshotState() snapshot := r.snapshotState()
equity := snapshot.Equity equity := snapshot.Equity
@@ -788,6 +805,13 @@ func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
sizeUSD = maxPositionValue sizeUSD = maxPositionValue
} }
// Reject positions below minimum size to avoid dust positions
if sizeUSD < MinPositionSizeUSD {
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
sizeUSD, MinPositionSizeUSD)
return 0
}
qty := sizeUSD / price qty := sizeUSD / price
if qty < 0 { if qty < 0 {
qty = 0 qty = 0
@@ -805,20 +829,37 @@ func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision
} }
func (r *Runner) resolveLeverage(requested int, symbol string) int { func (r *Runner) resolveLeverage(requested int, symbol string) int {
if requested > 0 {
return requested
}
sym := strings.ToUpper(symbol) sym := strings.ToUpper(symbol)
if sym == "BTCUSDT" || sym == "ETHUSDT" { isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
if r.cfg.Leverage.BTCETHLeverage > 0 {
return r.cfg.Leverage.BTCETHLeverage // Determine configured max leverage for this symbol type
var maxLeverage int
if isBTCETH {
maxLeverage = r.cfg.Leverage.BTCETHLeverage
if maxLeverage <= 0 {
maxLeverage = 10 // Default max for BTC/ETH
} }
} else { } else {
if r.cfg.Leverage.AltcoinLeverage > 0 { maxLeverage = r.cfg.Leverage.AltcoinLeverage
return r.cfg.Leverage.AltcoinLeverage if maxLeverage <= 0 {
maxLeverage = 5 // Default max for altcoins
} }
} }
return 5
// Use requested leverage if provided, otherwise use max as default
leverage := requested
if leverage <= 0 {
leverage = maxLeverage
}
// Enforce max leverage limit
if leverage > maxLeverage {
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
leverage, maxLeverage, symbol)
leverage = maxLeverage
}
return leverage
} }
func (r *Runner) remainingPosition(symbol, side string) float64 { func (r *Runner) remainingPosition(symbol, side string) float64 {
@@ -854,6 +895,12 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
list := make([]kernel.PositionInfo, 0, len(positions)) list := make([]kernel.PositionInfo, 0, len(positions))
for _, pos := range positions { for _, pos := range positions {
price := priceMap[pos.Symbol] price := priceMap[pos.Symbol]
pnl := unrealizedPnL(pos, price)
// Calculate P&L percentage based on entry notional (position cost)
pnlPct := 0.0
if pos.Notional > 0 {
pnlPct = (pnl / pos.Notional) * 100
}
list = append(list, kernel.PositionInfo{ list = append(list, kernel.PositionInfo{
Symbol: pos.Symbol, Symbol: pos.Symbol,
Side: pos.Side, Side: pos.Side,
@@ -861,8 +908,8 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
MarkPrice: price, MarkPrice: price,
Quantity: pos.Quantity, Quantity: pos.Quantity,
Leverage: pos.Leverage, Leverage: pos.Leverage,
UnrealizedPnL: unrealizedPnL(pos, price), UnrealizedPnL: pnl,
UnrealizedPnLPct: 0, UnrealizedPnLPct: pnlPct,
LiquidationPrice: pos.LiquidationPrice, LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin, MarginUsed: pos.Margin,
UpdateTime: time.Now().UnixMilli(), UpdateTime: time.Now().UnixMilli(),

View File

@@ -17,17 +17,17 @@ func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_checkpoints (run_id, payload, updated_at) INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
`, runID, data) `), runID, data)
return err return err
} }
func loadCheckpointDB(runID string) (*Checkpoint, error) { func loadCheckpointDB(runID string) (*Checkpoint, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, os.ErrNotExist return nil, os.ErrNotExist
@@ -57,25 +57,25 @@ func saveConfigDB(runID string, cfg *BacktestConfig) error {
if userID == "" { if userID == "" {
userID = "default" userID = "default"
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at) INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO NOTHING ON CONFLICT(run_id) DO NOTHING
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now) `), runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
WHERE run_id = ? WHERE run_id = ?
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID) `), userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
return err return err
} }
func loadConfigDB(runID string) (*BacktestConfig, error) { func loadConfigDB(runID string) (*BacktestConfig, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT config_json FROM backtest_runs WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -96,18 +96,18 @@ func saveRunMetadataDB(meta *RunMetadata) error {
if userID == "" { if userID == "" {
userID = "default" userID = "default"
} }
if _, err := persistenceDB.Exec(` if _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at) INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO NOTHING ON CONFLICT(run_id) DO NOTHING
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil { `), meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
return err return err
} }
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ? SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
WHERE run_id = ? WHERE run_id = ?
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID) `), userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
return err return err
} }
@@ -128,10 +128,10 @@ func loadRunMetadataDB(runID string) (*RunMetadata, error) {
createdISO string createdISO string
updatedISO string updatedISO string
) )
err := persistenceDB.QueryRow(` err := persistenceDB.QueryRow(convertQuery(`
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
FROM backtest_runs WHERE run_id = ? FROM backtest_runs WHERE run_id = ?
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO) `), runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -183,18 +183,18 @@ func loadRunIDsDB() ([]string, error) {
} }
func appendEquityPointDB(runID string, point EquityPoint) error { func appendEquityPointDB(runID string, point EquityPoint) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle) INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle) `), runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
return err return err
} }
func loadEquityPointsDB(runID string) ([]EquityPoint, error) { func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
`, runID) `), runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -211,18 +211,18 @@ func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
} }
func appendTradeEventDB(runID string, event TradeEvent) error { func appendTradeEventDB(runID string, event TradeEvent) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note) INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note) `), runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
return err return err
} }
func loadTradeEventsDB(runID string) ([]TradeEvent, error) { func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
`, runID) `), runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -243,17 +243,17 @@ func saveMetricsDB(runID string, metrics *Metrics) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_metrics (run_id, payload, updated_at) INSERT INTO backtest_metrics (run_id, payload, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
`, runID, data) `), runID, data)
return err return err
} }
func loadMetricsDB(runID string) (*Metrics, error) { func loadMetricsDB(runID string) (*Metrics, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_metrics WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -265,22 +265,21 @@ func loadMetricsDB(runID string) (*Metrics, error) {
} }
func saveProgressDB(runID string, payload progressPayload) error { func saveProgressDB(runID string, payload progressPayload) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ? SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
WHERE run_id = ? WHERE run_id = ?
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID) `), payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
return err return err
} }
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) { func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if cycle > 0 { if cycle > 0 {
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY created_at DESC LIMIT 1`, runID, cycle) rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
} else { } else {
rows, err = persistenceDB.Query(query+` ORDER BY created_at DESC LIMIT 1`, runID) rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY created_at DESC LIMIT 1`), runID)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -308,20 +307,20 @@ func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_decisions (run_id, cycle, payload) INSERT INTO backtest_decisions (run_id, cycle, payload)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, runID, record.CycleNumber, data) `), runID, record.CycleNumber, data)
return err return err
} }
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) { func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT payload FROM backtest_decisions SELECT payload FROM backtest_decisions
WHERE run_id = ? WHERE run_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, runID, limit, offset) `), runID, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -428,10 +427,10 @@ func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
} }
func writeDecisionLogsToZip(z *zip.Writer, runID string) error { func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT id, cycle, payload FROM backtest_decisions SELECT id, cycle, payload FROM backtest_decisions
WHERE run_id = ? ORDER BY id ASC WHERE run_id = ? ORDER BY id ASC
`, runID) `), runID)
if err != nil { if err != nil {
return err return err
} }
@@ -494,6 +493,6 @@ func listIndexEntriesDB() ([]RunIndexEntry, error) {
} }
func deleteRunDB(runID string) error { func deleteRunDB(runID string) error {
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID) _, err := persistenceDB.Exec(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
return err return err
} }

233
cmd/lighter_test/main.go Normal file
View File

@@ -0,0 +1,233 @@
// Lighter API Authentication Test Tool
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
lighterClient "github.com/elliottech/lighter-go/client"
lighterHTTP "github.com/elliottech/lighter-go/client/http"
)
func main() {
// Parse command line flags
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
flag.Parse()
if *walletAddr == "" || *apiKeyPrivateKey == "" {
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
fmt.Println("Options:")
fmt.Println(" -wallet Ethereum wallet address (required)")
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
fmt.Println(" -testnet Use testnet instead of mainnet")
os.Exit(1)
}
fmt.Println("=== Lighter API Authentication Test ===")
fmt.Printf("Wallet: %s\n", *walletAddr)
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
fmt.Printf("Testnet: %v\n", *testnet)
fmt.Println()
// Determine base URL
baseURL := "https://mainnet.zklighter.elliot.ai"
chainID := uint32(304)
if *testnet {
baseURL = "https://testnet.zklighter.elliot.ai"
chainID = uint32(300)
}
// Create HTTP client
httpClient := lighterHTTP.NewClient(baseURL)
client := &http.Client{Timeout: 30 * time.Second}
// Step 1: Get account info
fmt.Println("Step 1: Getting account info...")
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
if err != nil {
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
// Step 2: Create TxClient
fmt.Println("Step 2: Creating TxClient...")
txClient, err := lighterClient.NewTxClient(
httpClient,
*apiKeyPrivateKey,
accountInfo.AccountIndex,
uint8(*apiKeyIndex),
chainID,
)
if err != nil {
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
os.Exit(1)
}
fmt.Println("SUCCESS: TxClient created\n")
// Step 3: Generate auth token
fmt.Println("Step 3: Generating auth token...")
deadline := time.Now().Add(1 * time.Hour)
authToken, err := txClient.GetAuthToken(deadline)
if err != nil {
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
os.Exit(1)
}
fmt.Printf("SUCCESS: Auth token generated\n")
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
// Step 4: Test GetActiveOrders API with auth query parameter
fmt.Println("Step 4: Testing GetActiveOrders API...")
encodedAuth := url.QueryEscape(authToken)
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
baseURL, accountInfo.AccountIndex, encodedAuth)
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
fmt.Printf("ERROR: Failed to create request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
fmt.Printf("ERROR: Request failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("Status: %d\n", resp.StatusCode)
fmt.Printf("Response: %s\n\n", string(body))
// Parse response
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Orders []struct {
OrderID string `json:"order_id"`
Side string `json:"side"`
Type string `json:"type"`
Price string `json:"price"`
} `json:"orders"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
os.Exit(1)
}
if apiResp.Code != 200 {
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
fmt.Println("\n=== DIAGNOSTIC INFO ===")
fmt.Println("If you see 'invalid signature', possible causes:")
fmt.Println("1. API key is not registered on-chain")
fmt.Println("2. API key private key is incorrect")
fmt.Println("3. API key index is wrong")
fmt.Println("4. Account index mismatch")
fmt.Println("\nTo fix:")
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
fmt.Println("- Make sure you're using the correct API key private key")
os.Exit(1)
}
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
for i, order := range apiResp.Orders {
if i >= 5 {
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
break
}
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
}
// Step 5: Test GetTrades API (also needs auth)
fmt.Println("\nStep 5: Testing GetTrades API...")
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
baseURL, accountInfo.AccountIndex, encodedAuth)
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
tradesResp, err := client.Do(tradesReq)
if err != nil {
fmt.Printf("ERROR: Trades request failed: %v\n", err)
} else {
defer tradesResp.Body.Close()
tradesBody, _ := io.ReadAll(tradesResp.Body)
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
if tradesResp.StatusCode == 200 {
fmt.Println("SUCCESS: GetTrades API working")
} else {
fmt.Printf("Response: %s\n", string(tradesBody))
}
}
fmt.Println("\n=== ALL TESTS PASSED ===")
}
// AccountInfo represents Lighter account information
type AccountInfo struct {
AccountIndex int64 `json:"account_index"`
L1Address string `json:"l1_address"`
}
// getAccountByL1Address gets account info by L1 wallet address
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Parse response - can be in "accounts" or "sub_accounts" field
var apiResp struct {
Code int `json:"code"`
Message string `json:"message"`
Accounts []AccountInfo `json:"accounts"`
SubAccounts []AccountInfo `json:"sub_accounts"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
}
// Check main accounts first
if len(apiResp.Accounts) > 0 {
return &apiResp.Accounts[0], nil
}
// Check sub-accounts
if len(apiResp.SubAccounts) > 0 {
return &apiResp.SubAccounts[0], nil
}
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
}

View File

@@ -0,0 +1,50 @@
# ⚠️ Official Accounts & Anti-Impersonation Notice
## Legal Entity
| Field | Details |
|-------|---------|
| Company Name | **Cryonic Holdings Limited** |
| Company No. | 2193977 |
| Jurisdiction | British Virgin Islands |
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
| Contact Email | 0xccfelix@gmail.com |
## Official Social Media & Channels
| Platform | Official Account | Link | Status |
|----------|-----------------|------|--------|
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
## ⛔ Known Impersonation Accounts
The following accounts are **NOT affiliated** with the NoFx project:
| Platform | Account | Status |
|----------|---------|--------|
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
## How to Verify Authenticity
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
2. Our GitHub repository sidebar links directly to our official Twitter
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
5. Official contact email: 0xccfelix@gmail.com
## Report Impersonation
If you encounter accounts impersonating NoFx, please:
1. Report them on the respective platform
2. Open an issue in this repository to notify our team
---
*Last updated: 2026-03-01*
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*

View File

@@ -241,6 +241,7 @@ NOFX offers bounties for valuable contributions:
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md) - **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
- **Found a security issue?** → [Security Policy](../../SECURITY.md) - **Found a security issue?** → [Security Policy](../../SECURITY.md)
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community) - **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
--- ---

View File

@@ -1,27 +1,41 @@
# 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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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) **言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
--- ---
## AI 駆動の暗号通貨取引プラットフォーム
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
### コア機能 ### コア機能
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能 - **マルチ 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 トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡 - **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定 - **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ - **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
### 公式リンク
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします! > **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
## 開発者コミュニティ ## 開発者コミュニティ
@@ -30,6 +44,52 @@ Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニテ
--- ---
## 始める前に
NOFXを使用するには以下が必要です:
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
2. **AI モデル API キー** - サポートされているプロバイダーから取得コスト効率の良いDeepSeekを推奨
---
## サポート取引所
### CEX (中央集権型取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|----------|--------|-------------------------|
| **Binance** | ✅ サポート | [登録](https://www.binance.com/join?ref=NOFXENG) |
| **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 (分散型永久先物取引所)
| 取引所 | ステータス | 登録 (手数料割引) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
---
## サポート AI モデル
| AI モデル | ステータス | API キー取得 |
|----------|--------|-------------|
| **DeepSeek** | ✅ サポート | [API キー取得](https://platform.deepseek.com) |
| **Qwen** | ✅ サポート | [API キー取得](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ サポート | [API キー取得](https://platform.openai.com) |
| **Claude** | ✅ サポート | [API キー取得](https://console.anthropic.com) |
| **Gemini** | ✅ サポート | [API キー取得](https://aistudio.google.com) |
| **Grok** | ✅ サポート | [API キー取得](https://console.x.ai) |
| **Kimi** | ✅ サポート | [API キー取得](https://platform.moonshot.cn) |
---
## クイックスタート ## クイックスタート
### オプション 1: Docker デプロイ(推奨) ### オプション 1: Docker デプロイ(推奨)

View File

@@ -1,27 +1,41 @@
# 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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>AI 기반 금융 거래를 위한 인프라 레이어</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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) **언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
--- ---
## AI 기반 암호화폐 거래 플랫폼
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
### 핵심 기능 ### 핵심 기능
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능 - **다중 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 트레이더가 실시간으로 경쟁, 성과를 나란히 추적 - **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료 - **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그 - **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
### 공식 링크
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다! > **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
## 개발자 커뮤니티 ## 개발자 커뮤니티
@@ -30,6 +44,52 @@ Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://
--- ---
## 시작하기 전에
NOFX를 사용하려면 다음이 필요합니다:
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
---
## 지원 거래소
### CEX (중앙화 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|----------|--------|-------------------------|
| **Binance** | ✅ 지원 | [등록](https://www.binance.com/join?ref=NOFXENG) |
| **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 (탈중앙화 영구 선물 거래소)
| 거래소 | 상태 | 등록 (수수료 할인) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
---
## 지원 AI 모델
| AI 모델 | 상태 | API 키 받기 |
|----------|--------|-------------|
| **DeepSeek** | ✅ 지원 | [API 키 받기](https://platform.deepseek.com) |
| **Qwen** | ✅ 지원 | [API 키 받기](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ 지원 | [API 키 받기](https://platform.openai.com) |
| **Claude** | ✅ 지원 | [API 키 받기](https://console.anthropic.com) |
| **Gemini** | ✅ 지원 | [API 키 받기](https://aistudio.google.com) |
| **Grok** | ✅ 지원 | [API 키 받기](https://console.x.ai) |
| **Kimi** | ✅ 지원 | [API 키 받기](https://platform.moonshot.cn) |
---
## 빠른 시작 ## 빠른 시작
### 옵션 1: Docker 배포 (권장) ### 옵션 1: Docker 배포 (권장)

View File

@@ -1,27 +1,41 @@
# 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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>Инфраструктурный слой для AI-powered финансовой торговли</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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) **Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
--- ---
## Криптовалютная торговая платформа на базе ИИ
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
### Основные функции ### Основные функции
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время - **Мульти-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 трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок - **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс - **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений - **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
### Официальные ссылки
- **Официальный сайт**: [https://nofxai.com](https://nofxai.com)
- **Панель данных**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Документация API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами! > **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
## Сообщество разработчиков ## Сообщество разработчиков
@@ -30,6 +44,52 @@
--- ---
## Перед началом
Для использования NOFX вам понадобится:
1. **Аккаунт биржи** - Зарегистрируйтесь на поддерживаемой бирже и создайте API ключи с правами торговли
2. **API ключ AI модели** - Получите от любого поддерживаемого провайдера (рекомендуется DeepSeek для экономии)
---
## Поддерживаемые биржи
### CEX (Централизованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|----------|--------|-------------------------|
| **Binance** | ✅ Поддерживается | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
| **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 (Децентрализованные биржи)
| Биржа | Статус | Регистрация (скидка) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Поддерживается | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Поддерживается | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Поддерживается | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
---
## Поддерживаемые AI модели
| AI Модель | Статус | Получить API ключ |
|----------|--------|-------------|
| **DeepSeek** | ✅ Поддерживается | [Получить](https://platform.deepseek.com) |
| **Qwen** | ✅ Поддерживается | [Получить](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Поддерживается | [Получить](https://platform.openai.com) |
| **Claude** | ✅ Поддерживается | [Получить](https://console.anthropic.com) |
| **Gemini** | ✅ Поддерживается | [Получить](https://aistudio.google.com) |
| **Grok** | ✅ Поддерживается | [Получить](https://console.x.ai) |
| **Kimi** | ✅ Поддерживается | [Получить](https://platform.moonshot.cn) |
---
## Быстрый старт ## Быстрый старт
### Вариант 1: Docker развёртывание (рекомендуется) ### Вариант 1: Docker развёртывание (рекомендуется)

View File

@@ -1,27 +1,41 @@
# 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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>Інфраструктурний рівень для AI-powered фінансової торгівлі</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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) **Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
--- ---
## Криптовалютна торгова платформа на базі ШІ
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
### Основні функції ### Основні функції
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли - **Мульти-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 трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч - **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс - **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань - **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
### Офіційні посилання
- **Офіційний сайт**: [https://nofxai.com](https://nofxai.com)
- **Панель даних**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Документація API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами! > **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
## Спільнота розробників ## Спільнота розробників
@@ -30,6 +44,52 @@
--- ---
## Перед початком
Для використання NOFX вам знадобиться:
1. **Акаунт біржі** - Зареєструйтесь на підтримуваній біржі та створіть API ключі з правами торгівлі
2. **API ключ AI моделі** - Отримайте від будь-якого підтримуваного провайдера (рекомендується DeepSeek для економії)
---
## Підтримувані біржі
### CEX (Централізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|----------|--------|-------------------------|
| **Binance** | ✅ Підтримується | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
| **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 (Децентралізовані біржі)
| Біржа | Статус | Реєстрація (знижка) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Підтримується | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Підтримується | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Підтримується | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
---
## Підтримувані AI моделі
| AI Модель | Статус | Отримати API ключ |
|----------|--------|-------------|
| **DeepSeek** | ✅ Підтримується | [Отримати](https://platform.deepseek.com) |
| **Qwen** | ✅ Підтримується | [Отримати](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Підтримується | [Отримати](https://platform.openai.com) |
| **Claude** | ✅ Підтримується | [Отримати](https://console.anthropic.com) |
| **Gemini** | ✅ Підтримується | [Отримати](https://aistudio.google.com) |
| **Grok** | ✅ Підтримується | [Отримати](https://console.x.ai) |
| **Kimi** | ✅ Підтримується | [Отримати](https://platform.moonshot.cn) |
---
## Швидкий старт ## Швидкий старт
### Варіант 1: Docker розгортання (рекомендовано) ### Варіант 1: Docker розгортання (рекомендовано)

View File

@@ -1,27 +1,41 @@
# 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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>Lớp cơ sở hạ tầng cho giao dịch tài chính AI-powered</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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) **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 ### 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 - **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 - **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 - **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 - **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
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận - **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
### Liên Kết Chính Thức
- **Website Chính Thức**: [https://nofxai.com](https://nofxai.com)
- **Bảng Điều Khiển Dữ Liệu**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **Tài Liệu API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ! > **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
## Cộng Đồng Nhà Phát Triển ## Cộng Đồng Nhà Phát Triển
@@ -30,6 +44,52 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
--- ---
## Trước Khi Bắt Đầu
Để sử dụng NOFX, bạn cần:
1. **Tài khoản sàn giao dịch** - Đăng ký trên sàn được hỗ trợ và tạo API key với quyền giao dịch
2. **API Key mô hình AI** - Lấy từ nhà cung cấp được hỗ trợ (khuyến nghị DeepSeek để tiết kiệm chi phí)
---
## Sàn Giao Dịch Được Hỗ Trợ
### CEX (Sàn Tập Trung)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|----------|--------|-------------------------|
| **Binance** | ✅ Hỗ trợ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
| **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)
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Hỗ trợ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Hỗ trợ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Hỗ trợ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
---
## Mô Hình AI Được Hỗ Trợ
| Mô hình AI | Trạng thái | Lấy API Key |
|----------|--------|-------------|
| **DeepSeek** | ✅ Hỗ trợ | [Lấy API Key](https://platform.deepseek.com) |
| **Qwen** | ✅ Hỗ trợ | [Lấy API Key](https://dashscope.console.aliyun.com) |
| **OpenAI (GPT)** | ✅ Hỗ trợ | [Lấy API Key](https://platform.openai.com) |
| **Claude** | ✅ Hỗ trợ | [Lấy API Key](https://console.anthropic.com) |
| **Gemini** | ✅ Hỗ trợ | [Lấy API Key](https://aistudio.google.com) |
| **Grok** | ✅ Hỗ trợ | [Lấy API Key](https://console.x.ai) |
| **Kimi** | ✅ Hỗ trợ | [Lấy API Key](https://platform.moonshot.cn) |
---
## Bắt Đầu Nhanh ## Bắt Đầu Nhanh
### Tùy chọn 1: Triển khai Docker (Khuyến nghị) ### Tùy chọn 1: Triển khai Docker (Khuyến nghị)

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/) <p align="center">
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/) <strong>AI 驱动金融交易的基础设施层</strong>
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/) </p>
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<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 - 随时切换模型 - **多 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 交易员实时竞争,并排追踪表现 - **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置 - **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
@@ -34,6 +42,12 @@
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle) - **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official) - **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
### 官方链接
- **官网**: [https://nofxai.com](https://nofxai.com)
- **数据站点**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
- **API 文档**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试! > **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
## 开发者社区 ## 开发者社区
@@ -42,19 +56,12 @@
--- ---
## 截图 ## 开始之前
### 竞赛模式 - 实时 AI 对战 使用 NOFX 你需要准备:
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
### 仪表板 - 市场图表视图 1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png) 2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek性价比最高
*专业交易仪表板TradingView 风格图表*
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
--- ---
@@ -67,6 +74,9 @@
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) | | **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) | | **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 (去中心化永续交易所) ### Perp-DEX (去中心化永续交易所)
@@ -92,9 +102,25 @@
--- ---
## 截图
### 竞赛模式 - 实时 AI 对战
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
### 仪表板 - 市场图表视图
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png)
*专业交易仪表板TradingView 风格图表*
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
---
## 快速开始 ## 快速开始
### 一键安装 (推荐) ### 一键安装 (本地/服务器)
**Linux / macOS:** **Linux / macOS:**
```bash ```bash
@@ -103,6 +129,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
完成!打开浏览器访问 **http://127.0.0.1:3000** 完成!打开浏览器访问 **http://127.0.0.1:3000**
### 一键云部署 (Railway)
一键部署到 Railway - 无需自己搭建服务器:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
部署后Railway 会提供一个公网 URL 访问你的 NOFX 实例。
### Docker Compose (手动) ### Docker Compose (手动)
```bash ```bash

View File

@@ -0,0 +1,281 @@
# Market Regime Classification Framework
> A comprehensive market state identification system for quantitative trading strategy matching
---
## 1. Classification Dimensions Overview
Market state identification requires analysis across multiple dimensions:
| Dimension | Sub-dimensions | Description |
|-----------|---------------|-------------|
| **Trend** | Direction, Strength | Determine market movement direction and momentum |
| **Volatility** | Amplitude, Frequency | Measure price fluctuation characteristics |
| **Structure** | Pattern, Phase | Identify market structure and cycle position |
---
## 2. Primary Classification (5 Categories)
### 2.1 Classification Overview
| Code | Name | Key Characteristics | Suitable Strategies |
|------|------|---------------------|---------------------|
| `TREND_UP` | Uptrend | Higher highs & higher lows | Trend following, Breakout |
| `TREND_DOWN` | Downtrend | Lower highs & lower lows | Trend following, Short selling |
| `RANGE` | Range-bound | Price oscillates within bounds | Grid trading, Mean reversion |
| `TRANSITION` | Transition | Uncertain directional period | Wait & watch, Small positions |
| `BREAKOUT` | Breakout | Price breaks key levels | Breakout trading |
### 2.2 Identification Indicators
- **ADX (Average Directional Index)**: Measures trend strength
- ADX > 25: Clear trend exists
- ADX < 20: Range-bound market
- **EMA Alignment**: Determines trend direction
- EMA20 > EMA50 > EMA200: Bullish alignment
- EMA20 < EMA50 < EMA200: Bearish alignment
---
## 3. Secondary Classification (18 Sub-categories)
### 3.1 Uptrend Sub-categories (5 Types)
| Code | Name | Technical Features | Quantitative Indicators |
|------|------|-------------------|------------------------|
| `TU_STRONG_LOW_VOL` | Strong Uptrend · Low Vol | Steady rise, shallow pullbacks | ADX>40, ATR%<2%, Pullback<38.2% |
| `TU_STRONG_HIGH_VOL` | Strong Uptrend · High Vol | Rapid surge, high volatility | ADX>40, ATR%>4%, MACD histogram expanding |
| `TU_WEAK_CHOPPY` | Weak Uptrend · Choppy | Two steps forward, one back | ADX 20-30, RSI oscillating 50-70 |
| `TU_PARABOLIC` | Parabolic Acceleration | Exponential price increase | Price far from MA, RSI>80, Volume surge |
| `TU_EXHAUSTION` | Uptrend Exhaustion | New highs but weakening momentum | Price new high + MACD/RSI divergence |
**Strategy Matching:**
- Strong Low Vol: Heavy trend following, pyramid adding
- Strong High Vol: Medium position, trailing stops
- Weak Choppy: Light swing trading
- Parabolic: Cautious, prepare to exit
- Exhaustion: Reduce positions, prepare for reversal
### 3.2 Downtrend Sub-categories (5 Types)
| Code | Name | Technical Features | Quantitative Indicators |
|------|------|-------------------|------------------------|
| `TD_STRONG_LOW_VOL` | Strong Downtrend · Low Vol | Steady decline, weak bounces | ADX>40, ATR%<2%, Bounce<38.2% |
| `TD_STRONG_HIGH_VOL` | Strong Downtrend · High Vol | Panic selling, wild swings | ADX>40, ATR%>5%, VIX spike |
| `TD_WEAK_CHOPPY` | Weak Downtrend · Choppy | Grinding lower with bounces | ADX 20-30, RSI oscillating 30-50 |
| `TD_CAPITULATION` | Capitulation | High volume crash, extreme fear | RSI<20, Volume>3x average |
| `TD_EXHAUSTION` | Downtrend Exhaustion | New lows but selling pressure fading | Price new low + MACD/RSI divergence |
**Strategy Matching:**
- Strong Low Vol: Short trend following
- Strong High Vol: Stay flat or light hedge
- Weak Choppy: Wait for stabilization
- Capitulation: Light bottom fishing possible
- Exhaustion: Gradually build long positions
### 3.3 Range Sub-categories (4 Types)
| Code | Name | Technical Features | Quantitative Indicators |
|------|------|-------------------|------------------------|
| `RG_TIGHT_LOW_VOL` | Tight Range · Low Vol | Extreme contraction, coiling | BB Width<2%, ATR at new lows |
| `RG_TIGHT_HIGH_VOL` | Tight Range · High Vol | Violent swings within range | BB Width<3%, ATR%>3% |
| `RG_WIDE_LOW_VOL` | Wide Range · Low Vol | Large range, slow movement | BB Width>5%, ATR%<2% |
| `RG_WIDE_HIGH_VOL` | Wide Range · High Vol | Large range, fast movement | BB Width>5%, ATR%>3% |
**Strategy Matching:**
- Tight Low Vol: Dense grid, wait for breakout
- Tight High Vol: Fast grid, small frequent profits
- Wide Low Vol: Sparse grid, patient holding
- Wide High Vol: Swing trading, high profit targets
### 3.4 Transition (2 Types)
| Code | Name | Technical Features | Quantitative Indicators |
|------|------|-------------------|------------------------|
| `TR_BOTTOM_FORMING` | Bottom Forming | Decline slowing, testing support | Price stabilizing + Volume drying up + RSI divergence |
| `TR_TOP_FORMING` | Top Forming | Rally slowing, testing resistance | Price stalling + Volume drying up + RSI divergence |
### 3.5 Breakout (2 Types)
| Code | Name | Technical Features | Quantitative Indicators |
|------|------|-------------------|------------------------|
| `BK_UPWARD` | Upward Breakout | Breaking resistance with volume | Price>Previous high, Volume>2x, BB breakout |
| `BK_DOWNWARD` | Downward Breakout | Breaking support with volume | Price<Previous low, Volume>2x, BB breakdown |
---
## 4. Tertiary Classification (36 Ultra-fine Categories)
### 4.1 Trend Phase Classification
Uptrend lifecycle consists of 5 phases:
| Phase Code | Name | Description | Quantitative Criteria |
|------------|------|-------------|----------------------|
| `TU_S1_INITIATION` | Uptrend Initiation | First break above MA or previous high | MACD bullish cross, Price>EMA20 |
| `TU_S2_ACCELERATION` | Uptrend Acceleration | Momentum increasing, slope steepening | MACD histogram expanding, ADX rising |
| `TU_S3_MAIN_WAVE` | Main Wave | Sustained rise, shallow pullbacks | RSI 60-80, Pullbacks hold EMA20 |
| `TU_S4_EXHAUSTION` | Uptrend Exhaustion | Slowing momentum, divergences appearing | RSI divergence, MACD divergence |
| `TU_S5_REVERSAL` | Trend Reversal | Breakdown, trend ending | Break below EMA50, MACD bearish cross |
Downtrend phases follow same pattern: `TD_S1` through `TD_S5`
### 4.2 Range Position Classification
| Position Code | Name | Description | Strategy Suggestion |
|---------------|------|-------------|---------------------|
| `RG_UPPER` | Upper Range | Price near resistance | Bias toward short |
| `RG_MIDDLE` | Mid Range | Price near middle band | Neutral grid trading |
| `RG_LOWER` | Lower Range | Price near support | Bias toward long |
| `RG_SQUEEZE` | Squeeze Pattern | Highs and lows converging | Wait for direction |
| `RG_EXPAND` | Expanding Pattern | Highs and lows diverging | Boundary reversal |
### 4.3 Volatility Grades
| Code | Name | ATR% | BB Width | Strategy Suggestion |
|------|------|------|----------|---------------------|
| `VOL_EXTREME_LOW` | Extreme Low Vol | <1% | <1.5% | Option selling |
| `VOL_LOW` | Low Volatility | 1-2% | 1.5-2.5% | Grid / Mean reversion |
| `VOL_NORMAL` | Normal Volatility | 2-3% | 2.5-4% | Trend following |
| `VOL_HIGH` | High Volatility | 3-5% | 4-6% | Momentum / Breakout |
| `VOL_EXTREME_HIGH` | Extreme High Vol | >5% | >6% | Reduce exposure / Hedge |
---
## 5. Complete State Encoding Rules
### 5.1 Encoding Format
```
{Primary}_{Volatility}_{Phase}_{Position}
```
### 5.2 Encoding Examples
| Full Code | Interpretation |
|-----------|----------------|
| `TU_LV_S3_M` | Uptrend_LowVol_MainWave_Middle |
| `TD_HV_S2_L` | Downtrend_HighVol_Acceleration_Lower |
| `RG_NV_SQ_U` | Range_NormalVol_Squeeze_Upper |
| `BK_HV_UP_M` | Breakout_HighVol_Upward_Middle |
---
## 6. Core Identification Indicators
### 6.1 Trend Indicators
| Indicator | Calculation | Criteria |
|-----------|-------------|----------|
| ADX | 14-period Average Directional Index | >40 Strong, 25-40 Medium, <25 Weak/Range |
| Trend Score | Composite EMA/MACD/Price structure | -100 to +100, Positive=Bullish, Negative=Bearish |
| EMA Alignment | Relative position of EMA20/50/200 | Bullish/Bearish/Mixed alignment |
### 6.2 Volatility Indicators
| Indicator | Calculation | Purpose |
|-----------|-------------|---------|
| ATR Percent | ATR(14) / Current Price × 100% | Measure relative volatility |
| BB Width | (Upper - Lower) / Middle × 100% | Measure price range |
| Volatility Rank | Current vol percentile in history | Determine vol level |
### 6.3 Momentum Indicators
| Indicator | Calculation | Criteria |
|-----------|-------------|----------|
| RSI | 14-period Relative Strength Index | >70 Overbought, <30 Oversold, 50 Neutral |
| MACD Histogram | MACD - Signal | Positive=Bullish momentum, Negative=Bearish |
| Momentum Score | Composite RSI/MACD/Volume | Measure current momentum |
### 6.4 Structure Indicators
| Indicator | Description | Purpose |
|-----------|-------------|---------|
| Swing Structure | HH/HL/LH/LL sequence | Determine trend structure |
| Support/Resistance | Key price levels | Define trading range |
| Volume Profile | Volume-price relationship | Validate price action |
---
## 7. Strategy Matching Matrix
### 7.1 Regime-Strategy Mapping
| Regime Type | Recommended Strategy | Position Size | Stop Loss |
|-------------|---------------------|---------------|-----------|
| Strong Uptrend · Low Vol | Trend following + Pyramid | 60-80% | ATR×2 |
| Strong Uptrend · High Vol | Momentum + Quick profit | 40-60% | ATR×1.5 |
| Uptrend Exhaustion | Reduce + Reversal short | 20-30% | Previous high |
| Panic Decline | Wait or light bottom fish | 10-20% | Wide stop |
| Low Vol Range | Grid trading | 50-70% | Range boundary |
| High Vol Range | Swing trading | 30-50% | ATR×2 |
| Squeeze Pattern | Wait for breakout | 10-20% | - |
| Upward Breakout | Chase + Add on pullback | 50-70% | Breakout level |
| Bottom Formation | Scale in gradually | 20-40% | New low |
### 7.2 Grid Strategy Parameter Matching
| Range Type | Grid Levels | Grid Spacing | Other Parameters |
|------------|-------------|--------------|------------------|
| Tight Low Vol | 30-50 levels | Small spacing | Enable Maker Only |
| Tight High Vol | 15-25 levels | Small spacing | Fast execution mode |
| Wide Low Vol | 10-20 levels | Large spacing | Patient execution |
| Wide High Vol | 15-25 levels | Large spacing | High profit targets |
| Squeeze Pattern | Pause grid | - | Wait for breakout signal |
| Upper Range | Short bias | Medium | Increase sell weight |
| Lower Range | Long bias | Medium | Increase buy weight |
---
## 8. Real-time Monitoring Guidelines
### 8.1 State Transition Triggers
| Current State | Trigger Condition | Transitions To |
|---------------|-------------------|----------------|
| Range | Price breakout + Volume + ADX rising | Breakout |
| Uptrend | RSI divergence + Volume decline | Exhaustion |
| Downtrend | RSI divergence + Volume decline | Exhaustion |
| Breakout | Failed breakout, price returns | Range |
| Exhaustion | Confirmed reversal breakout | Opposite trend |
### 8.2 Risk Control Rules
| Regime State | Max Position | Risk Per Trade | Special Rules |
|--------------|--------------|----------------|---------------|
| Strong Trend | 80% | 2% | Adding allowed |
| Weak Trend | 50% | 1.5% | No adding |
| Range | 60% | 1% | Diversified holding |
| Transition | 30% | 1% | Reduce activity |
| High Volatility | 40% | 0.5% | Wide stops |
---
## 9. Appendix
### 9.1 Abbreviation Reference
| Abbrev | Full Form | Description |
|--------|-----------|-------------|
| TU | Trend Up | Upward trend |
| TD | Trend Down | Downward trend |
| RG | Range | Range-bound market |
| TR | Transition | Trend transition |
| BK | Breakout | Breakout pattern |
| LV | Low Volatility | Low volatility regime |
| HV | High Volatility | High volatility regime |
| NV | Normal Volatility | Normal volatility regime |
| XLV | Extreme Low Vol | Extremely low volatility |
| XHV | Extreme High Vol | Extremely high volatility |
### 9.2 Document Information
- Version: v1.0
- Created: January 2026
- Applicable: Cryptocurrency, Forex, Stocks, and other financial markets
---
*This document is designed for market state identification and strategy matching in quantitative trading systems*

View File

@@ -0,0 +1,281 @@
# 市场行情精细分类体系
> 用于量化交易策略匹配的市场状态识别框架
---
## 一、分类维度概览
市场状态识别需要从多个维度进行分析:
| 维度 | 子维度 | 说明 |
|------|--------|------|
| **趋势维度** | 方向、强度 | 判断市场运动方向和力度 |
| **波动维度** | 幅度、频率 | 衡量价格波动特征 |
| **结构维度** | 形态、阶段 | 识别市场结构和所处周期 |
---
## 二、一级分类5大类
### 2.1 分类总览
| 代码 | 名称 | 核心特征 | 适合策略 |
|------|------|----------|----------|
| `TREND_UP` | 上涨趋势 | 高点/低点持续抬升 | 趋势跟踪、突破追涨 |
| `TREND_DOWN` | 下跌趋势 | 高点/低点持续降低 | 趋势跟踪、做空策略 |
| `RANGE` | 震荡区间 | 价格在区间内波动 | 网格交易、均值回归 |
| `TRANSITION` | 趋势转换 | 方向不明确的过渡期 | 观望、小仓位试探 |
| `BREAKOUT` | 突破行情 | 价格突破关键位置 | 突破追踪策略 |
### 2.2 识别指标
- **ADX平均方向指数**:衡量趋势强度
- ADX > 25存在明确趋势
- ADX < 20震荡市场
- **EMA排列**判断趋势方向
- EMA20 > EMA50 > EMA200多头排列
- EMA20 < EMA50 < EMA200空头排列
---
## 三、二级分类18细分类
### 3.1 上涨趋势细分5种
| 代码 | 名称 | 技术特征 | 量化指标 |
|------|------|----------|----------|
| `TU_STRONG_LOW_VOL` | 强势上涨·低波动 | 稳步上涨回调幅度小 | ADX>40, ATR%<2%, 回调<38.2% |
| `TU_STRONG_HIGH_VOL` | 强势上涨·高波动 | 快速拉升波动剧烈 | ADX>40, ATR%>4%, MACD柱放大 |
| `TU_WEAK_CHOPPY` | 弱势上涨·震荡 | 涨三退二,反复磨蹭 | ADX 20-30, RSI在50-70震荡 |
| `TU_PARABOLIC` | 抛物线加速 | 指数级加速上涨 | 价格远离均线, RSI>80, 成交量放大 |
| `TU_EXHAUSTION` | 上涨衰竭 | 创新高但动能减弱 | 价格新高 + MACD/RSI顶背离 |
**策略匹配:**
- 强势低波动:重仓趋势跟踪,金字塔加仓
- 强势高波动:中等仓位,设置移动止盈
- 弱势震荡:轻仓波段,高抛低吸
- 抛物线加速:谨慎追涨,准备离场
- 上涨衰竭:减仓观望,准备反转做空
### 3.2 下跌趋势细分5种
| 代码 | 名称 | 技术特征 | 量化指标 |
|------|------|----------|----------|
| `TD_STRONG_LOW_VOL` | 强势下跌·低波动 | 稳步下跌,反弹无力 | ADX>40, ATR%<2%, 反弹<38.2% |
| `TD_STRONG_HIGH_VOL` | 强势下跌·高波动 | 恐慌抛售波动剧烈 | ADX>40, ATR%>5%, 恐慌指数飙升 |
| `TD_WEAK_CHOPPY` | 弱势下跌·震荡 | 跌跌涨涨,磨底过程 | ADX 20-30, RSI在30-50震荡 |
| `TD_CAPITULATION` | 恐慌投降 | 放量暴跌,情绪极端 | RSI<20, 成交量>3倍均量 |
| `TD_EXHAUSTION` | 下跌衰竭 | 创新低但卖压减弱 | 价格新低 + MACD/RSI底背离 |
**策略匹配:**
- 强势低波动:空头趋势跟踪
- 强势高波动:观望或轻仓对冲
- 弱势震荡:等待企稳信号
- 恐慌投降:极端情况可轻仓抄底
- 下跌衰竭:逐步建立多头仓位
### 3.3 震荡区间细分4种
| 代码 | 名称 | 技术特征 | 量化指标 |
|------|------|----------|----------|
| `RG_TIGHT_LOW_VOL` | 窄幅震荡·低波动 | 极度收敛,蓄势待发 | 布林带宽度<2%, ATR创新低 |
| `RG_TIGHT_HIGH_VOL` | 窄幅震荡·高波动 | 区间内剧烈波动 | 布林带宽度<3%, ATR%>3% |
| `RG_WIDE_LOW_VOL` | 宽幅震荡·低波动 | 大区间慢速波动 | 布林带宽度>5%, ATR%<2% |
| `RG_WIDE_HIGH_VOL` | 宽幅震荡·高波动 | 大区间快速波动 | 布林带宽度>5%, ATR%>3% |
**策略匹配:**
- 窄幅低波动:密集网格,等待突破
- 窄幅高波动:快速网格,小利润多次
- 宽幅低波动:稀疏网格,耐心持有
- 宽幅高波动:波段交易,高利润目标
### 3.4 转换过渡2种
| 代码 | 名称 | 技术特征 | 量化指标 |
|------|------|----------|----------|
| `TR_BOTTOM_FORMING` | 底部形成中 | 下跌放缓,试探支撑 | 价格止跌 + 成交量萎缩 + RSI底背离 |
| `TR_TOP_FORMING` | 顶部形成中 | 上涨放缓,试探压力 | 价格滞涨 + 成交量萎缩 + RSI顶背离 |
### 3.5 突破行情2种
| 代码 | 名称 | 技术特征 | 量化指标 |
|------|------|----------|----------|
| `BK_UPWARD` | 向上突破 | 突破阻力位并放量 | 价格>前高, 成交量>2倍, 布林带突破 |
| `BK_DOWNWARD` | 向下突破 | 跌破支撑位并放量 | 价格<前低, 成交量>2倍, 布林带跌破 |
---
## 四、三级分类36超细分类
### 4.1 趋势阶段细分
上涨趋势生命周期分为5个阶段
| 阶段代码 | 名称 | 特征描述 | 量化判断标准 |
|----------|------|----------|--------------|
| `TU_S1_INITIATION` | 上涨启动期 | 首次突破均线或前高 | MACD金叉, 价格突破EMA20 |
| `TU_S2_ACCELERATION` | 上涨加速期 | 动能增强,斜率加大 | MACD柱持续增大, ADX上升 |
| `TU_S3_MAIN_WAVE` | 主升浪阶段 | 持续上涨,回调幅度浅 | RSI维持60-80, 回调不破EMA20 |
| `TU_S4_EXHAUSTION` | 上涨衰竭期 | 涨速放缓,出现背离 | RSI顶背离, MACD顶背离 |
| `TU_S5_REVERSAL` | 趋势反转期 | 破位下跌,趋势结束 | 跌破EMA50, MACD死叉 |
下跌趋势同理,代码为 `TD_S1``TD_S5`
### 4.2 震荡位置细分
| 位置代码 | 名称 | 特征描述 | 策略建议 |
|----------|------|----------|----------|
| `RG_UPPER` | 区间上沿震荡 | 价格接近阻力位 | 偏空操作为主 |
| `RG_MIDDLE` | 区间中部震荡 | 价格在中轨附近 | 双向网格交易 |
| `RG_LOWER` | 区间下沿震荡 | 价格接近支撑位 | 偏多操作为主 |
| `RG_SQUEEZE` | 收敛三角震荡 | 高低点逐渐收窄 | 等待方向选择 |
| `RG_EXPAND` | 扩散三角震荡 | 高低点逐渐扩张 | 边界反转操作 |
### 4.3 波动率等级
| 代码 | 名称 | ATR百分比 | 布林带宽度 | 策略建议 |
|------|------|-----------|------------|----------|
| `VOL_EXTREME_LOW` | 极低波动 | <1% | <1.5% | 期权卖方策略 |
| `VOL_LOW` | 低波动 | 1-2% | 1.5-2.5% | 网格/均值回归 |
| `VOL_NORMAL` | 正常波动 | 2-3% | 2.5-4% | 趋势跟踪 |
| `VOL_HIGH` | 高波动 | 3-5% | 4-6% | 动量/突破 |
| `VOL_EXTREME_HIGH` | 极高波动 | >5% | >6% | 减仓/对冲 |
---
## 五、完整状态编码规则
### 5.1 编码格式
```
{一级分类}_{波动等级}_{阶段}_{位置}
```
### 5.2 编码示例
| 完整代码 | 含义解释 |
|----------|----------|
| `TU_LV_S3_M` | 上涨趋势_低波动_主升浪_中部位置 |
| `TD_HV_S2_L` | 下跌趋势_高波动_加速期_下部位置 |
| `RG_NV_SQ_U` | 震荡区间_正常波动_收敛形态_上沿位置 |
| `BK_HV_UP_M` | 突破行情_高波动_向上突破_中部位置 |
---
## 六、核心识别指标
### 6.1 趋势指标
| 指标 | 计算方法 | 判断标准 |
|------|----------|----------|
| ADX | 14周期平均方向指数 | >40强趋势, 25-40中等, <25弱/震荡 |
| 趋势评分 | 综合EMA/MACD/价格结构 | -100到+100, 正数多头负数空头 |
| EMA排列 | EMA20/50/200相对位置 | 多头排列/空头排列/混乱 |
### 6.2 波动指标
| 指标 | 计算方法 | 用途 |
|------|----------|------|
| ATR百分比 | ATR(14) / 当前价格 × 100% | 衡量相对波动幅度 |
| 布林带宽度 | (上轨-下轨) / 中轨 × 100% | 衡量价格波动区间 |
| 波动率排名 | 当前波动在历史中的分位 | 判断波动率高低 |
### 6.3 动量指标
| 指标 | 计算方法 | 判断标准 |
|------|----------|----------|
| RSI | 14周期相对强弱指数 | >70超买, <30超卖, 50中性 |
| MACD柱 | MACD - Signal | 正数多头动能负数空头动能 |
| 动量评分 | 综合RSI/MACD/成交量 | 衡量当前动能强弱 |
### 6.4 结构指标
| 指标 | 说明 | 用途 |
|------|------|------|
| 高低点结构 | HH/HL/LH/LL序列 | 判断趋势结构 |
| 支撑阻力位 | 关键价格水平 | 确定交易区间 |
| 成交量形态 | 量价配合关系 | 验证价格走势 |
---
## 七、策略匹配矩阵
### 7.1 行情类型与策略对应
| 行情类型 | 推荐策略 | 建议仓位 | 止损设置 |
|----------|----------|----------|----------|
| 强势上涨·低波动 | 趋势跟踪+金字塔加仓 | 60-80% | ATR×2 |
| 强势上涨·高波动 | 动量突破+快速止盈 | 40-60% | ATR×1.5 |
| 上涨衰竭期 | 减仓+反转信号做空 | 20-30% | 前高 |
| 恐慌下跌 | 观望或轻仓抄底 | 10-20% | 宽止损 |
| 低波动震荡 | 网格交易 | 50-70% | 区间边界 |
| 高波动震荡 | 波段高抛低吸 | 30-50% | ATR×2 |
| 收敛等待 | 蓄势等突破 | 10-20% | - |
| 向上突破 | 追涨+回踩加仓 | 50-70% | 突破位 |
| 底部形成 | 分批建仓 | 20-40% | 新低 |
### 7.2 网格策略参数匹配
| 震荡类型 | 网格层数 | 网格间距 | 其他参数 |
|----------|----------|----------|----------|
| 窄幅低波动 | 30-50层 | 小间距 | 启用Maker Only |
| 窄幅高波动 | 15-25层 | 小间距 | 快速成交模式 |
| 宽幅低波动 | 10-20层 | 大间距 | 耐心等待成交 |
| 宽幅高波动 | 15-25层 | 大间距 | 高利润目标 |
| 收敛形态 | 暂停网格 | - | 等待突破信号 |
| 区间上沿 | 偏空配置 | 中等 | 卖单权重增加 |
| 区间下沿 | 偏多配置 | 中等 | 买单权重增加 |
---
## 八、实时监控建议
### 8.1 状态转换触发条件
| 当前状态 | 触发条件 | 转换到 |
|----------|----------|--------|
| 震荡区间 | 价格突破+放量+ADX上升 | 突破行情 |
| 上涨趋势 | RSI顶背离+成交量萎缩 | 上涨衰竭 |
| 下跌趋势 | RSI底背离+成交量萎缩 | 下跌衰竭 |
| 突破行情 | 突破失败回落 | 震荡区间 |
| 趋势衰竭 | 反向突破确认 | 反向趋势 |
### 8.2 风险控制规则
| 行情状态 | 最大仓位 | 单笔风险 | 特殊规则 |
|----------|----------|----------|----------|
| 强趋势 | 80% | 2% | 可加仓 |
| 弱趋势 | 50% | 1.5% | 不加仓 |
| 震荡 | 60% | 1% | 分散持仓 |
| 转换期 | 30% | 1% | 减少操作 |
| 高波动 | 40% | 0.5% | 宽止损 |
---
## 九、附录
### 9.1 缩写对照表
| 缩写 | 英文全称 | 中文含义 |
|------|----------|----------|
| TU | Trend Up | 上涨趋势 |
| TD | Trend Down | 下跌趋势 |
| RG | Range | 震荡区间 |
| TR | Transition | 趋势转换 |
| BK | Breakout | 突破行情 |
| LV | Low Volatility | 低波动 |
| HV | High Volatility | 高波动 |
| NV | Normal Volatility | 正常波动 |
| XLV | Extreme Low Vol | 极低波动 |
| XHV | Extreme High Vol | 极高波动 |
### 9.2 版本信息
- 文档版本v1.0
- 创建日期2026年1月
- 适用范围加密货币外汇股票等金融市场
---
*本文档用于量化交易系统的市场状态识别和策略匹配*

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,151 @@
# 网格策略市场状态识别与风控设计
## 概述
增强网格策略的市场状态识别能力,实现震荡/趋势的精准判断,并根据不同震荡级别自动调整网格参数和风控策略。
---
## 一、市场状态识别
### 1.1 识别维度3个
| 维度 | 指标 | 作用 |
|------|------|------|
| 价格波动 | ATR14 + Bollinger带宽 | 判断震荡幅度 |
| 趋势强度 | EMA20/50距离 + MACD | 判断是否有趋势 |
| 动量 | RSI14 + 1h/4h涨跌幅 | 判断超买超卖 |
### 1.2 箱体指标(新增)
基于1小时K线的多周期Donchian通道
| 箱体级别 | 周期 | 覆盖时间 | 用途 |
|----------|------|----------|------|
| 短期箱体 | 72根1小时 | 3天 | 日内波动边界 |
| 中期箱体 | 240根1小时 | 10天 | 周级别震荡区间 |
| 长期箱体 | 500根1小时 | ~21天 | 大级别趋势边界 |
### 1.3 判断方式
由AI综合分析以上指标 + 原始K线序列 + 箱体位置,输出市场状态判断。
---
## 二、震荡分级与网格策略
### 2.1 四级震荡分类
| 级别 | 特征 | 判断依据 |
|------|------|----------|
| 窄幅震荡 | 价格在短期箱体内小幅波动 | Bollinger带宽 < 2%ATR低 |
| 标准震荡 | 价格在中期箱体内正常波动 | Bollinger带宽 2-3%ATR正常 |
| 宽幅震荡 | 价格接近中期箱体边缘 | Bollinger带宽 3-4%ATR较高 |
| 剧烈震荡 | 价格接近长期箱体边缘 | Bollinger带宽 > 4%ATR高 |
### 2.2 各级别对应的网格策略
| 级别 | 网格密度 | 网格范围 | 单格仓位 | 总仓位上限 | 有效杠杆上限 |
|------|----------|----------|----------|------------|--------------|
| 窄幅震荡 | 密集 | 窄 | 小 | 30-40% | 2x |
| 标准震荡 | 正常 | 中等 | 正常 | 60-70% | 3-4x |
| 宽幅震荡 | 稀疏 | 宽 | 正常 | 50-60% | 3x |
| 剧烈震荡 | 最稀疏 | 最宽 | 小 | 30-40% | 2x |
**核心原则:**
- 窄幅震荡:单格仓位小 + 总仓位上限低(防击穿风险)
- 剧烈震荡:同样保守(随时可能变趋势)
- 标准震荡:才是放量的最佳时机
---
## 三、突破处理与恢复机制
### 3.1 突破判断与处理
**确认方式:** 收盘价突破箱体后持续3根1小时K线不回箱体
| 箱体级别 | 突破处理 |
|----------|----------|
| 短期箱体突破 | 降低仓位到 50% |
| 中期箱体突破 | 暂停网格 + 取消挂单 |
| 长期箱体突破 | 暂停网格 + 取消挂单 + 平掉所有持仓 |
### 3.2 假突破恢复
**价格回到箱体内 → 以50%仓位恢复网格**
---
## 四、前端风控面板
### 4.1 需要展示的信息
| 类别 | 显示内容 |
|------|----------|
| 杠杆信息 | 当前杠杆、有效杠杆、系统推荐杠杆 |
| 仓位信息 | 当前仓位、最大仓位、仓位占比 |
| 爆仓信息 | 爆仓价格、爆仓距离(%) |
| 市场状态 | 当前震荡级别(窄幅/标准/宽幅/剧烈) |
| 箱体状态 | 短期/中期/长期箱体上下沿、当前价格位置 |
---
## 五、实现要点
### 5.1 后端新增
1. **箱体指标计算** (`market/data.go`)
- 新增 `calculateDonchian(klines, period)` 函数
- 返回 upper(最高价), lower(最低价)
- 支持72/240/500三个周期
2. **市场状态评估** (`kernel/grid_engine.go`)
- 更新AI prompt加入箱体指标和K线序列
- AI输出震荡级别判断
3. **网格参数动态调整** (`trader/auto_trader_grid.go`)
- 根据震荡级别自动调整:网格密度、范围、仓位、杠杆
- 实现有效杠杆上限控制
4. **突破处理逻辑** (`trader/auto_trader_grid.go`)
- 实现三级箱体突破检测
- 实现3根K线确认逻辑
- 实现降级恢复机制
### 5.2 前端新增
1. **风控面板组件**
- 杠杆信息展示
- 仓位信息展示
- 爆仓信息展示
- 市场状态展示
- 箱体状态可视化
### 5.3 数据模型更新
1. **GridConfigModel** 新增字段:
- `EffectiveLeverageLimit` - 有效杠杆上限
- `ShortBoxPeriod` - 短期箱体周期 (默认72)
- `MidBoxPeriod` - 中期箱体周期 (默认240)
- `LongBoxPeriod` - 长期箱体周期 (默认500)
2. **GridInstanceModel** 新增字段:
- `CurrentRegimeLevel` - 当前震荡级别 (narrow/standard/wide/volatile)
- `ShortBoxUpper/Lower` - 短期箱体上下沿
- `MidBoxUpper/Lower` - 中期箱体上下沿
- `LongBoxUpper/Lower` - 长期箱体上下沿
- `BreakoutStatus` - 突破状态 (none/short/mid/long)
- `BreakoutConfirmCount` - 突破确认K线计数
---
## 六、风险控制总结
| 控制点 | 机制 |
|--------|------|
| 仓位控制 | 根据震荡级别限制总仓位上限 (30-70%) |
| 杠杆控制 | 根据震荡级别限制有效杠杆 (2-4x) |
| 突破保护 | 三级箱体突破分级处理 |
| 假突破恢复 | 50%仓位降级恢复 |
| 爆仓预防 | 前端展示爆仓距离,系统自动限制杠杆 |

File diff suppressed because it is too large Load Diff

3
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0 github.com/sonirico/go-hyperliquid v0.26.0
@@ -23,6 +22,7 @@ require (
require ( require (
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect 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/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect
@@ -44,6 +44,7 @@ require (
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // 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/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect

6
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/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 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 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 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 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= 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/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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -182,8 +186,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=

View File

@@ -1,6 +1,7 @@
package kernel package kernel
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -8,6 +9,7 @@ import (
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos" "nofx/provider/nofxos"
"nofx/security" "nofx/security"
"nofx/store" "nofx/store"
@@ -130,7 +132,8 @@ type Context struct {
// Decision AI trading decision // Decision AI trading decision
type Decision struct { type Decision struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait" Action string `json:"action"` // Standard: "open_long", "open_short", "close_long", "close_short", "hold", "wait"
// Grid actions: "place_buy_limit", "place_sell_limit", "cancel_order", "cancel_all_orders", "pause_grid", "resume_grid", "adjust_grid"
// Opening position parameters // Opening position parameters
Leverage int `json:"leverage,omitempty"` Leverage int `json:"leverage,omitempty"`
@@ -138,6 +141,12 @@ type Decision struct {
StopLoss float64 `json:"stop_loss,omitempty"` StopLoss float64 `json:"stop_loss,omitempty"`
TakeProfit float64 `json:"take_profit,omitempty"` TakeProfit float64 `json:"take_profit,omitempty"`
// Grid trading parameters
Price float64 `json:"price,omitempty"` // Limit order price (for grid)
Quantity float64 `json:"quantity,omitempty"` // Order quantity (for grid)
LevelIndex int `json:"level_index,omitempty"` // Grid level index
OrderID string `json:"order_id,omitempty"` // Order ID (for cancel)
// Common parameters // Common parameters
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100) Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
@@ -440,6 +449,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil return e.filterExcludedCoins(coins), nil
case "oi_top": case "oi_top":
@@ -459,6 +469,65 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_low":
// 持仓减少榜,适合做空
if !coinSource.UseOILow {
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getOILowCoins(coinSource.OILowLimit)
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "hyper_all":
// All Hyperliquid perp coins
if !coinSource.UseHyperAll {
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all 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.getHyperAllCoins()
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "hyper_main":
// Top N Hyperliquid coins by 24h volume
if !coinSource.UseHyperMain {
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main 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.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil return e.filterExcludedCoins(coins), nil
case "mixed": case "mixed":
@@ -484,6 +553,39 @@ 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")
}
}
}
if coinSource.UseHyperAll {
hyperCoins, err := e.getHyperAllCoins()
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
} else {
for _, coin := range hyperCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
}
}
}
if coinSource.UseHyperMain {
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
} else {
for _, coin := range hyperMainCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
}
}
}
for _, symbol := range coinSource.StaticCoins { for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol) symbol = market.Normalize(symbol)
if _, exists := symbolSources[symbol]; !exists { if _, exists := symbolSources[symbol]; !exists {
@@ -554,7 +656,7 @@ func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 10
} }
positions, err := e.nofxosClient.GetOITopPositions() positions, err := e.nofxosClient.GetOITopPositions()
@@ -576,6 +678,76 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil return candidates, nil
} }
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 10
}
positions, err := e.nofxosClient.GetOILowPositions()
if err != nil {
return nil, err
}
var candidates []CandidateCoin
for i, pos := range positions {
if i >= limit {
break
}
symbol := market.Normalize(pos.Symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"oi_low"},
})
}
return candidates, nil
}
// getHyperAllCoins returns all available Hyperliquid perpetual coins
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
ctx := context.Background()
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_all"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
return candidates, nil
}
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
}
ctx := context.Background()
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_main"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
return candidates, nil
}
// ============================================================================ // ============================================================================
// External & Quant Data // External & Quant Data
// ============================================================================ // ============================================================================
@@ -1282,15 +1454,56 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 { if len(sources) > 1 {
return " (AI500+OI_Top dual signal)" // 多信号源组合
hasAI500 := false
hasOITop := false
hasOILow := false
hasHyperAll := false
hasHyperMain := false
for _, s := range sources {
switch s {
case "ai500":
hasAI500 = true
case "oi_top":
hasOITop = true
case "oi_low":
hasOILow = true
case "hyper_all":
hasHyperAll = true
case "hyper_main":
hasHyperMain = 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)"
}
if hasHyperMain && hasAI500 {
return " (HyperMain+AI500)"
}
if hasHyperAll || hasHyperMain {
return " (Hyperliquid)"
}
return " (Multiple sources)"
} else if len(sources) == 1 { } else if len(sources) == 1 {
switch sources[0] { switch sources[0] {
case "ai500": case "ai500":
return " (AI500)" return " (AI500)"
case "oi_top": case "oi_top":
return " (OI_Top position growth)" return " (OI_Top 持仓增加)"
case "oi_low":
return " (OI_Low 持仓减少)"
case "static": case "static":
return " (Manual selection)" return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
} }
} }
return "" return ""
@@ -1760,8 +1973,8 @@ func compactArrayOpen(s string) string {
// ============================================================================ // ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error { func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i, decision := range decisions { for i := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil { if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err) return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
} }
} }

618
kernel/grid_engine.go Normal file
View File

@@ -0,0 +1,618 @@
package kernel
import (
"encoding/json"
"fmt"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
"strings"
"time"
)
// ============================================================================
// Grid Trading Context and Types
// ============================================================================
// GridLevelInfo represents a single grid level's current state
type GridLevelInfo struct {
Index int `json:"index"` // Level index (0 = lowest)
Price float64 `json:"price"` // Target price for this level
State string `json:"state"` // "empty", "pending", "filled"
Side string `json:"side"` // "buy" or "sell"
OrderID string `json:"order_id"` // Current order ID (if pending)
OrderQuantity float64 `json:"order_quantity"` // Order quantity
PositionSize float64 `json:"position_size"` // Position size (if filled)
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
}
// GridContext contains all information needed for AI grid decision making
type GridContext struct {
// Basic info
Symbol string `json:"symbol"`
CurrentTime string `json:"current_time"`
CurrentPrice float64 `json:"current_price"`
// Grid configuration
GridCount int `json:"grid_count"`
TotalInvestment float64 `json:"total_investment"`
Leverage int `json:"leverage"`
UpperPrice float64 `json:"upper_price"`
LowerPrice float64 `json:"lower_price"`
GridSpacing float64 `json:"grid_spacing"`
Distribution string `json:"distribution"`
// Grid state
Levels []GridLevelInfo `json:"levels"`
ActiveOrderCount int `json:"active_order_count"`
FilledLevelCount int `json:"filled_level_count"`
IsPaused bool `json:"is_paused"`
// Market data
ATR14 float64 `json:"atr14"`
BollingerUpper float64 `json:"bollinger_upper"`
BollingerMiddle float64 `json:"bollinger_middle"`
BollingerLower float64 `json:"bollinger_lower"`
BollingerWidth float64 `json:"bollinger_width"` // Percentage
EMA20 float64 `json:"ema20"`
EMA50 float64 `json:"ema50"`
EMADistance float64 `json:"ema_distance"` // Percentage
RSI14 float64 `json:"rsi14"`
MACD float64 `json:"macd"`
MACDSignal float64 `json:"macd_signal"`
MACDHistogram float64 `json:"macd_histogram"`
FundingRate float64 `json:"funding_rate"`
Volume24h float64 `json:"volume_24h"`
PriceChange1h float64 `json:"price_change_1h"`
PriceChange4h float64 `json:"price_change_4h"`
// Account info
TotalEquity float64 `json:"total_equity"`
AvailableBalance float64 `json:"available_balance"`
CurrentPosition float64 `json:"current_position"` // Net position size
UnrealizedPnL float64 `json:"unrealized_pnl"`
// Performance
TotalProfit float64 `json:"total_profit"`
TotalTrades int `json:"total_trades"`
WinningTrades int `json:"winning_trades"`
MaxDrawdown float64 `json:"max_drawdown"`
DailyPnL float64 `json:"daily_pnl"`
// 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"`
}
// ============================================================================
// Grid Prompt Building
// ============================================================================
// BuildGridSystemPrompt builds the system prompt for grid trading AI
func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string {
if lang == "zh" {
return buildGridSystemPromptZh(config)
}
return buildGridSystemPromptEn(config)
}
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
return fmt.Sprintf(`# 你是一个专业的网格交易AI
## 角色定义
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
1. 判断当前市场状态(震荡/趋势/高波动)
2. 决定是否需要调整网格或暂停交易
3. 管理每个网格层级的订单
## 网格配置
- 交易对: %s
- 网格层数: %d
- 总投资: %.2f USDT
- 杠杆: %dx
- 价格分布: %s
## 决策规则
### 市场状态判断
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
### 可执行的操作
- place_buy_limit: 在指定价格下买入限价单
- place_sell_limit: 在指定价格下卖出限价单
- cancel_order: 取消指定订单
- cancel_all_orders: 取消所有订单
- pause_grid: 暂停网格交易(趋势市场时)
- resume_grid: 恢复网格交易(震荡市场时)
- adjust_grid: 调整网格边界
- hold: 保持当前状态不操作
## 输出格式
输出JSON数组每个决策包含:
- symbol: 交易对
- action: 操作类型
- price: 价格(限价单用)
- quantity: 数量
- level_index: 网格层级索引
- order_id: 订单ID取消订单用
- confidence: 置信度 0-100
- reasoning: 决策理由
示例:
[
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近下买单"},
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
]
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
}
func buildGridSystemPromptEn(config *store.GridStrategyConfig) string {
return fmt.Sprintf(`# You are a Professional Grid Trading AI
## Role Definition
You are an experienced grid trading expert managing a grid strategy for %s. Your tasks are:
1. Assess current market regime (ranging/trending/volatile)
2. Decide whether to adjust grid or pause trading
3. Manage orders at each grid level
## Grid Configuration
- Symbol: %s
- Grid Levels: %d
- Total Investment: %.2f USDT
- Leverage: %dx
- Distribution: %s
## Decision Rules
### Market Regime Assessment
- **Ranging Market** (ideal for grid): Bollinger width < 3%%, EMA20/50 distance < 1%%, price near middle band
- **Trending Market** (pause grid): Bollinger width > 4%%, EMA20/50 distance > 2%%, price breaking bands
- **High Volatility** (caution): ATR spike, erratic price movement
### Available Actions
- place_buy_limit: Place buy limit order at specified price
- place_sell_limit: Place sell limit order at specified price
- cancel_order: Cancel specific order
- cancel_all_orders: Cancel all orders
- pause_grid: Pause grid trading (in trending market)
- resume_grid: Resume grid trading (in ranging market)
- adjust_grid: Adjust grid boundaries
- hold: Maintain current state
## Output Format
Output JSON array, each decision contains:
- symbol: Trading pair
- action: Action type
- price: Price (for limit orders)
- quantity: Quantity
- level_index: Grid level index
- order_id: Order ID (for cancel)
- confidence: Confidence 0-100
- reasoning: Decision reason
Example:
[
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price approaching, place buy order"},
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market ranging, maintain current grid"}
]
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
}
// BuildGridUserPrompt builds the user prompt with current grid context
func BuildGridUserPrompt(ctx *GridContext, lang string) string {
if lang == "zh" {
return buildGridUserPromptZh(ctx)
}
return buildGridUserPromptEn(ctx)
}
func buildGridUserPromptZh(ctx *GridContext) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
// Market data section
sb.WriteString("## 市场数据\n")
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
sb.WriteString("\n")
// Box Indicator Section
if ctx.BoxData != nil {
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
sb.WriteString("|----------|------|------|------|\n")
shortWidth := 0.0
midWidth := 0.0
longWidth := 0.0
if ctx.BoxData.CurrentPrice > 0 {
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
}
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
// Check position relative to boxes
price := ctx.BoxData.CurrentPrice
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
}
sb.WriteString("\n")
}
// Account section
sb.WriteString("## 账户状态\n")
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
sb.WriteString("\n")
// Grid state section
sb.WriteString("## 网格状态\n")
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
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
sb.WriteString("## 网格层级详情\n")
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
for _, level := range ctx.Levels {
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
level.Index, level.Price, level.State, level.Side,
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
}
sb.WriteString("\n")
// Performance section
sb.WriteString("## 绩效统计\n")
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
sb.WriteString("\n")
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
sb.WriteString("输出JSON数组格式的决策列表。\n")
return sb.String()
}
func buildGridUserPromptEn(ctx *GridContext) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
// Market data section
sb.WriteString("## Market Data\n")
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
sb.WriteString("\n")
// Box Indicator Section
if ctx.BoxData != nil {
sb.WriteString("## Box Indicators (Donchian Channels)\n\n")
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
sb.WriteString("|-----------|-------|-------|-------|\n")
shortWidth := 0.0
midWidth := 0.0
longWidth := 0.0
if ctx.BoxData.CurrentPrice > 0 {
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
}
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
// Check position relative to boxes
price := ctx.BoxData.CurrentPrice
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
sb.WriteString("⚠️ BREAKOUT: Price outside long-term box!\n")
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
sb.WriteString("⚠️ WARNING: Price approaching long-term box boundary\n")
}
sb.WriteString("\n")
}
// Account section
sb.WriteString("## Account Status\n")
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net)\n", ctx.CurrentPosition))
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
sb.WriteString("\n")
// Grid state section
sb.WriteString("## Grid Status\n")
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
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
sb.WriteString("## Grid Levels Detail\n")
sb.WriteString("| Level | Price | State | Side | Order Qty | Position | Unrealized PnL |\n")
sb.WriteString("|-------|-------|-------|------|-----------|----------|----------------|\n")
for _, level := range ctx.Levels {
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
level.Index, level.Price, level.State, level.Side,
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
}
sb.WriteString("\n")
// Performance section
sb.WriteString("## Performance Stats\n")
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
sb.WriteString(fmt.Sprintf("- Daily PnL: $%.2f\n", ctx.DailyPnL))
sb.WriteString("\n")
sb.WriteString("## Please analyze the data above and make grid trading decisions\n")
sb.WriteString("Output a JSON array of decisions.\n")
return sb.String()
}
// ============================================================================
// Grid Decision Functions
// ============================================================================
// GetGridDecisions gets AI decisions for grid trading
func GetGridDecisions(ctx *GridContext, mcpClient mcp.AIClient, config *store.GridStrategyConfig, lang string) (*FullDecision, error) {
startTime := time.Now()
// Build prompts
systemPrompt := BuildGridSystemPrompt(config, lang)
userPrompt := BuildGridUserPrompt(ctx, lang)
logger.Infof("🤖 [Grid] Calling AI for grid decisions...")
// Call AI
response, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
// Parse decisions from response
decisions, err := parseGridDecisions(response, ctx.Symbol)
if err != nil {
logger.Warnf("Failed to parse grid decisions: %v", err)
// Return hold decision as fallback
decisions = []Decision{{
Symbol: ctx.Symbol,
Action: "hold",
Confidence: 50,
Reasoning: "Failed to parse AI response, holding current state",
}}
}
duration := time.Since(startTime).Milliseconds()
logger.Infof("⏱️ [Grid] AI call duration: %d ms, decisions: %d", duration, len(decisions))
// Extract chain of thought from response
cotTrace := extractCoTTrace(response)
return &FullDecision{
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
CoTTrace: cotTrace,
Decisions: decisions,
RawResponse: response,
AIRequestDurationMs: duration,
Timestamp: time.Now(),
}, nil
}
// parseGridDecisions parses AI response into grid decisions
func parseGridDecisions(response string, symbol string) ([]Decision, error) {
// Try to find JSON array in response
jsonStr := extractJSONArray(response)
if jsonStr == "" {
return nil, fmt.Errorf("no JSON array found in response")
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonStr), &decisions); err != nil {
return nil, fmt.Errorf("failed to parse JSON: %w", err)
}
// Validate and set default symbol
for i := range decisions {
if decisions[i].Symbol == "" {
decisions[i].Symbol = symbol
}
// Validate action
if !isValidGridAction(decisions[i].Action) {
logger.Warnf("Invalid grid action: %s", decisions[i].Action)
}
}
return decisions, nil
}
// extractJSONArray extracts JSON array from AI response
func extractJSONArray(response string) string {
// Try to find ```json code block first
matches := reJSONFence.FindStringSubmatch(response)
if len(matches) > 1 {
return matches[1]
}
// Try to find raw JSON array
matches = reJSONArray.FindStringSubmatch(response)
if len(matches) > 0 {
return matches[0]
}
return ""
}
// isValidGridAction checks if action is a valid grid action
func isValidGridAction(action string) bool {
validActions := map[string]bool{
"place_buy_limit": true,
"place_sell_limit": true,
"cancel_order": true,
"cancel_all_orders": true,
"pause_grid": true,
"resume_grid": true,
"adjust_grid": true,
"hold": true,
// Also support standard actions for compatibility
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
}
return validActions[action]
}
// ============================================================================
// Grid Context Builder Helpers
// ============================================================================
// BuildGridContextFromMarketData builds grid context from market data
func BuildGridContextFromMarketData(mktData *market.Data, config *store.GridStrategyConfig) *GridContext {
ctx := &GridContext{
Symbol: config.Symbol,
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
CurrentPrice: mktData.CurrentPrice,
// Grid config
GridCount: config.GridCount,
TotalInvestment: config.TotalInvestment,
Leverage: config.Leverage,
Distribution: config.Distribution,
// Market data
PriceChange1h: mktData.PriceChange1h,
PriceChange4h: mktData.PriceChange4h,
FundingRate: mktData.FundingRate,
}
// Extract indicators from timeframe data
if mktData.TimeframeData != nil {
if tf5m, ok := mktData.TimeframeData["5m"]; ok {
if len(tf5m.BOLLUpper) > 0 {
ctx.BollingerUpper = tf5m.BOLLUpper[len(tf5m.BOLLUpper)-1]
ctx.BollingerMiddle = tf5m.BOLLMiddle[len(tf5m.BOLLMiddle)-1]
ctx.BollingerLower = tf5m.BOLLLower[len(tf5m.BOLLLower)-1]
if ctx.BollingerMiddle > 0 {
ctx.BollingerWidth = (ctx.BollingerUpper - ctx.BollingerLower) / ctx.BollingerMiddle * 100
}
}
ctx.ATR14 = tf5m.ATR14
if len(tf5m.RSI14Values) > 0 {
ctx.RSI14 = tf5m.RSI14Values[len(tf5m.RSI14Values)-1]
}
}
}
// Extract longer term context
if mktData.LongerTermContext != nil {
if ctx.ATR14 == 0 {
ctx.ATR14 = mktData.LongerTermContext.ATR14
}
ctx.EMA50 = mktData.LongerTermContext.EMA50
}
ctx.EMA20 = mktData.CurrentEMA20
ctx.MACD = mktData.CurrentMACD
// Calculate EMA distance
if ctx.EMA50 > 0 {
ctx.EMADistance = (ctx.EMA20 - ctx.EMA50) / ctx.EMA50 * 100
}
return ctx
}
// Helper function for max
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -1,7 +1,5 @@
package kernel package kernel
import "fmt"
// ============================================================================ // ============================================================================
// Trading Data Schema - 交易数据字典 // Trading Data Schema - 交易数据字典
// ============================================================================ // ============================================================================
@@ -481,18 +479,6 @@ func getSchemaPromptZH() string {
prompt += formatFieldDefZH(key, field) prompt += formatFieldDefZH(key, field)
} }
// 交易规则
prompt += "\n## ⚖️ 交易规则\n\n"
prompt += "### 风险管理\n"
for name, rule := range TradingRules.RiskManagement {
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
}
prompt += "\n### 出场信号\n"
for name, rule := range TradingRules.ExitSignals {
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
}
// OI解读 // OI解读
prompt += "\n## 💹 持仓量(OI)变化解读\n\n" prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n" prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
@@ -500,14 +486,6 @@ func getSchemaPromptZH() string {
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n" prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n" prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
// 常见错误
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
for i, mistake := range CommonMistakes {
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
}
return prompt return prompt
} }
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
prompt += formatFieldDefEN(key, field) prompt += formatFieldDefEN(key, field)
} }
// Trading Rules
prompt += "\n## ⚖️ Trading Rules\n\n"
prompt += "### Risk Management\n"
for name, rule := range TradingRules.RiskManagement {
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
}
prompt += "\n### Exit Signals\n"
for name, rule := range TradingRules.ExitSignals {
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
}
// OI Interpretation // OI Interpretation
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n" prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n" prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
@@ -559,14 +525,6 @@ func getSchemaPromptEN() string {
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n" prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n" prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
// Common Mistakes
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
for i, mistake := range CommonMistakes {
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
}
return prompt return prompt
} }

View File

@@ -147,10 +147,7 @@ func TestGetSchemaPrompt(t *testing.T) {
"交易指标", "交易指标",
"持仓指标", "持仓指标",
"市场数据", "市场数据",
"交易规则",
"风险管理",
"持仓量(OI)变化解读", "持仓量(OI)变化解读",
"常见错误",
} }
for _, keyword := range mustContain { for _, keyword := range mustContain {
@@ -174,10 +171,7 @@ func TestGetSchemaPrompt(t *testing.T) {
"Trade Metrics", "Trade Metrics",
"Position Metrics", "Position Metrics",
"Market Data", "Market Data",
"Trading Rules",
"Risk Management",
"Open Interest", "Open Interest",
"Common Mistakes",
} }
for _, keyword := range mustContain { for _, keyword := range mustContain {

View File

@@ -78,7 +78,7 @@ func main() {
logger.Fatalf("❌ Failed to initialize database: %v", err) logger.Fatalf("❌ Failed to initialize database: %v", err)
} }
defer st.Close() defer st.Close()
backtest.UseDatabase(st.DB()) backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
// Initialize installation ID for experience improvement (anonymous statistics) // Initialize installation ID for experience improvement (anonymous statistics)
initInstallationID(st) initInstallationID(st)

View File

@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
// Concurrently fetch data for each trader // Concurrently fetch data for each trader
for i, t := range traders { for i, t := range traders {
go func(index int, trader *trader.AutoTrader) { go func(index int, trader *trader.AutoTrader) {
// Set timeout to 3 seconds for single trader // Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
// Use channel for timeout control // Use channel for timeout control
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
} }
case err := <-errorChan: case err := <-errorChan:
// Failed to get account info // Failed to get account info
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err) logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
traderData = map[string]interface{}{ traderData = map[string]interface{}{
"trader_id": trader.GetID(), "trader_id": trader.GetID(),
"trader_name": trader.GetName(), "trader_name": trader.GetName(),
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
} }
case <-ctx.Done(): case <-ctx.Done():
// Timeout // Timeout
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID()) logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
traderData = map[string]interface{}{ traderData = map[string]interface{}{
"trader_id": trader.GetID(), "trader_id": trader.GetID(),
"trader_name": trader.GetName(), "trader_name": trader.GetName(),
@@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
return result, nil return result, nil
} }
// RemoveTrader removes a trader from memory (does not affect database) // RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration // Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first // If the trader is running, it will be stopped first
@@ -664,11 +663,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
QwenKey: "", QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL, CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName, CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance, InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin, IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition, ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig, StrategyConfig: strategyConfig,
} }
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v", logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
@@ -690,9 +689,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey) traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey) traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase) 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": case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey) traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster": case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner traderConfig.AsterSigner = exchangeCfg.AsterSigner
@@ -703,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey) traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
traderConfig.LighterTestnet = exchangeCfg.Testnet traderConfig.LighterTestnet = exchangeCfg.Testnet
case "indodax":
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
} }
// Set API keys based on AI model (convert EncryptedString to string) // Set API keys based on AI model (convert EncryptedString to string)

View File

@@ -31,7 +31,7 @@ var (
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication // 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) // 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 // Map interval string to coinank enum
var coinankInterval coinank_enum.Interval var coinankInterval coinank_enum.Interval
switch interval { switch interval {
@@ -67,13 +67,44 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
return nil, fmt.Errorf("unsupported interval: %s", interval) 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) // Call CoinAnk free/open API (no authentication required)
ctx := context.Background() ctx := context.Background()
ts := time.Now().UnixMilli() ts := time.Now().UnixMilli()
// Use "To" side to search backward from current time (get historical klines) // 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 { 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 // Convert coinank kline format to market.Kline format
@@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
return klines, nil 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) { 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 klines3m, klines4h []Kline
var err error var err error
// Normalize symbol // Normalize symbol
@@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) {
// Check if this is an xyz dex asset (use Hyperliquid API) // Check if this is an xyz dex asset (use Hyperliquid API)
isXyzAsset := IsXyzDexAsset(symbol) 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) // 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) // Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100) klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err) return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
} }
} else { } else {
// Use CoinAnk for regular crypto assets // Use CoinAnk for regular crypto assets with exchange-specific data
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100) klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
if err != nil { 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 // Get 4-hour K-line data
if isXyzAsset { if useHyperliquidAPI {
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100) klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
if err != nil { if err != nil {
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err) return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
} }
} else { } else {
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100) klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
if err != nil { 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 continue
} }
} else { } else {
// Use CoinAnk for regular crypto assets // Use CoinAnk for regular crypto assets (default to Binance)
klines, err = getKlinesFromCoinAnk(symbol, tf, 200) klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
if err != nil { if err != nil {
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err) logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
continue continue
@@ -1068,6 +1107,11 @@ func Normalize(symbol string) string {
return "xyz:" + base 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 // For regular crypto assets
if strings.HasSuffix(symbol, "USDT") { if strings.HasSuffix(symbol, "USDT") {
return symbol return symbol
@@ -1210,3 +1254,91 @@ func ExportCalculateATR(klines []Kline, period int) float64 {
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) { func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
return calculateBOLL(klines, period, multiplier) return calculateBOLL(klines, period, multiplier)
} }
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
if len(klines) == 0 || period <= 0 {
return 0, 0
}
// Use all available klines if period > len(klines)
start := len(klines) - period
if start < 0 {
start = 0
}
upper = klines[start].High
lower = klines[start].Low
for i := start + 1; i < len(klines); i++ {
if klines[i].High > upper {
upper = klines[i].High
}
if klines[i].Low < lower {
lower = klines[i].Low
}
}
return upper, lower
}
// ExportCalculateDonchian exports calculateDonchian for testing
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
return calculateDonchian(klines, period)
}
// Box period constants (in 1h candles)
const (
ShortBoxPeriod = 72 // 3 days of 1h candles
MidBoxPeriod = 240 // 10 days of 1h candles
LongBoxPeriod = 500 // ~21 days of 1h candles
)
// calculateBoxData calculates multi-period box data from klines
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
box := &BoxData{
CurrentPrice: currentPrice,
}
if len(klines) == 0 {
return box
}
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
return box
}
// ExportCalculateBoxData exports calculateBoxData for testing
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
return calculateBoxData(klines, currentPrice)
}
// GetBoxData fetches 1h klines and calculates box data for a symbol
func GetBoxData(symbol string) (*BoxData, error) {
symbol = Normalize(symbol)
// Fetch 500 1h klines
var klines []Kline
var err error
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
}
if err != nil {
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
}
if len(klines) == 0 {
return nil, fmt.Errorf("no kline data available")
}
currentPrice := klines[len(klines)-1].Close
return calculateBoxData(klines, currentPrice), nil
}

View File

@@ -500,3 +500,86 @@ func TestIsStaleData_EmptyKlines(t *testing.T) {
t.Error("Expected false for empty klines, got true") t.Error("Expected false for empty klines, got true")
} }
} }
func TestCalculateDonchian(t *testing.T) {
// Create test klines with known high/low values
klines := []Kline{
{High: 100, Low: 90},
{High: 105, Low: 88},
{High: 102, Low: 92},
{High: 108, Low: 85},
{High: 103, Low: 91},
}
upper, lower := ExportCalculateDonchian(klines, 5)
if upper != 108 {
t.Errorf("Expected upper = 108, got %v", upper)
}
if lower != 85 {
t.Errorf("Expected lower = 85, got %v", lower)
}
}
func TestCalculateDonchian_PartialPeriod(t *testing.T) {
klines := []Kline{
{High: 100, Low: 90},
{High: 105, Low: 88},
}
upper, lower := ExportCalculateDonchian(klines, 10)
// Should use all available klines when period > len(klines)
if upper != 105 {
t.Errorf("Expected upper = 105, got %v", upper)
}
if lower != 88 {
t.Errorf("Expected lower = 88, got %v", lower)
}
}
func TestCalculateDonchian_InvalidPeriod(t *testing.T) {
klines := []Kline{
{High: 100, Low: 90},
}
// Zero period should return (0, 0)
upper, lower := ExportCalculateDonchian(klines, 0)
if upper != 0 || lower != 0 {
t.Errorf("Expected (0, 0) for zero period, got (%v, %v)", upper, lower)
}
// Negative period should return (0, 0)
upper, lower = ExportCalculateDonchian(klines, -1)
if upper != 0 || lower != 0 {
t.Errorf("Expected (0, 0) for negative period, got (%v, %v)", upper, lower)
}
}
func TestCalculateBoxData(t *testing.T) {
// Create synthetic kline data
klines := make([]Kline, 500)
for i := 0; i < 500; i++ {
basePrice := 100.0
klines[i] = Kline{
High: basePrice + float64(i%10),
Low: basePrice - float64(i%10),
Close: basePrice,
}
}
box := ExportCalculateBoxData(klines, 100.0)
if box.ShortUpper == 0 || box.ShortLower == 0 {
t.Error("Short box should not be zero")
}
if box.MidUpper == 0 || box.MidLower == 0 {
t.Error("Mid box should not be zero")
}
if box.LongUpper == 0 || box.LongLower == 0 {
t.Error("Long box should not be zero")
}
if box.CurrentPrice != 100.0 {
t.Errorf("Expected CurrentPrice = 100.0, got %v", box.CurrentPrice)
}
}

View File

@@ -187,3 +187,76 @@ var config = Config{
}, },
UpdateInterval: 60, // 1 minute UpdateInterval: 60, // 1 minute
} }
// BoxData represents multi-period Donchian channel (box) data
type BoxData struct {
// Short-term box (72 1h candles = 3 days)
ShortUpper float64 `json:"short_upper"`
ShortLower float64 `json:"short_lower"`
// Mid-term box (240 1h candles = 10 days)
MidUpper float64 `json:"mid_upper"`
MidLower float64 `json:"mid_lower"`
// Long-term box (500 1h candles = ~21 days)
LongUpper float64 `json:"long_upper"`
LongLower float64 `json:"long_lower"`
// Current price position relative to boxes
CurrentPrice float64 `json:"current_price"`
}
// RegimeLevel represents the ranging classification level
type RegimeLevel string
const (
RegimeLevelNarrow RegimeLevel = "narrow" // 窄幅震荡
RegimeLevelStandard RegimeLevel = "standard" // 标准震荡
RegimeLevelWide RegimeLevel = "wide" // 宽幅震荡
RegimeLevelVolatile RegimeLevel = "volatile" // 剧烈震荡
RegimeLevelTrending RegimeLevel = "trending" // 趋势
)
// BreakoutLevel represents which box level has been broken
type BreakoutLevel string
const (
BreakoutNone BreakoutLevel = "none"
BreakoutShort BreakoutLevel = "short"
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

@@ -9,7 +9,7 @@ import (
const ( const (
ProviderClaude = "claude" ProviderClaude = "claude"
DefaultClaudeBaseURL = "https://api.anthropic.com/v1" DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
DefaultClaudeModel = "claude-opus-4-5-20251101" DefaultClaudeModel = "claude-opus-4-6"
) )
type ClaudeClient struct { type ClaudeClient struct {

View File

@@ -7,7 +7,7 @@ import (
const ( const (
ProviderOpenAI = "openai" ProviderOpenAI = "openai"
DefaultOpenAIBaseURL = "https://api.openai.com/v1" DefaultOpenAIBaseURL = "https://api.openai.com/v1"
DefaultOpenAIModel = "gpt-5.2" DefaultOpenAIModel = "gpt-5.4"
) )
type OpenAIClient struct { type OpenAIClient struct {

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

@@ -0,0 +1,223 @@
package hyperliquid
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"nofx/logger"
"sort"
"sync"
"time"
)
const (
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
cacheDuration = 24 * time.Hour // Cache for 24 hours
)
// CoinInfo represents basic coin information
type CoinInfo struct {
Symbol string `json:"symbol"`
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
}
// CoinProvider provides Hyperliquid coin lists
type CoinProvider struct {
mu sync.RWMutex
allCoins []CoinInfo
mainCoins []CoinInfo
lastUpdated time.Time
httpClient *http.Client
}
var (
defaultProvider *CoinProvider
providerOnce sync.Once
)
// GetProvider returns the singleton CoinProvider instance
func GetProvider() *CoinProvider {
providerOnce.Do(func() {
defaultProvider = &CoinProvider{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
})
return defaultProvider
}
// metaResponse represents the response from Hyperliquid meta endpoint
type metaResponse struct {
Universe []struct {
Name string `json:"name"`
} `json:"universe"`
}
// assetCtx represents asset context with volume data
type assetCtx struct {
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
}
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
// Request metaAndAssetCtxs to get both coin names and volume data
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
bytes.NewReader(reqBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch coin data: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
// Response is an array: [meta, [assetCtxs...]]
var rawResp []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(rawResp) < 2 {
return fmt.Errorf("unexpected response format")
}
// Parse meta
var meta metaResponse
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
return fmt.Errorf("failed to parse meta: %w", err)
}
// Parse asset contexts
var ctxs []assetCtx
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
return fmt.Errorf("failed to parse asset contexts: %w", err)
}
// Build coin list with volume
var coins []CoinInfo
for i, u := range meta.Universe {
var vol float64
if i < len(ctxs) {
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
}
coins = append(coins, CoinInfo{
Symbol: u.Name,
Volume24h: vol,
})
}
// Sort by volume descending
sort.Slice(coins, func(i, j int) bool {
return coins[i].Volume24h > coins[j].Volume24h
})
p.mu.Lock()
defer p.mu.Unlock()
p.allCoins = coins
// Main coins are top 20 by volume
if len(coins) > 20 {
p.mainCoins = coins[:20]
} else {
p.mainCoins = coins
}
p.lastUpdated = time.Now()
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
return nil
}
// ensureUpdated checks if cache is stale and refreshes if needed
func (p *CoinProvider) ensureUpdated(ctx context.Context) error {
p.mu.RLock()
needsUpdate := time.Since(p.lastUpdated) > cacheDuration || len(p.allCoins) == 0
p.mu.RUnlock()
if needsUpdate {
return p.fetchCoins(ctx)
}
return nil
}
// GetAllCoins returns all available Hyperliquid perp coins
func (p *CoinProvider) GetAllCoins(ctx context.Context) ([]CoinInfo, error) {
if err := p.ensureUpdated(ctx); err != nil {
return nil, err
}
p.mu.RLock()
defer p.mu.RUnlock()
// Return a copy to avoid mutation
result := make([]CoinInfo, len(p.allCoins))
copy(result, p.allCoins)
return result, nil
}
// GetMainCoins returns top N coins by 24h volume
func (p *CoinProvider) GetMainCoins(ctx context.Context, limit int) ([]CoinInfo, error) {
if err := p.ensureUpdated(ctx); err != nil {
return nil, err
}
p.mu.RLock()
defer p.mu.RUnlock()
if limit <= 0 {
limit = 20
}
// Return top N coins
count := limit
if count > len(p.allCoins) {
count = len(p.allCoins)
}
result := make([]CoinInfo, count)
copy(result, p.allCoins[:count])
return result, nil
}
// GetCoinSymbols returns just the symbol names (for compatibility)
func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
coins, err := GetProvider().GetAllCoins(ctx)
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol
}
return symbols, nil
}
// GetMainCoinSymbols returns top N coin symbols by volume
func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
coins, err := GetProvider().GetMainCoins(ctx, limit)
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol
}
return symbols, nil
}
// ForceRefresh forces a refresh of the coin cache
func (p *CoinProvider) ForceRefresh(ctx context.Context) error {
return p.fetchCoins(ctx)
}

View File

@@ -73,8 +73,10 @@ func (c *Client) fetchAI500() ([]CoinData, error) {
return nil, fmt.Errorf("API returned failure status") return nil, fmt.Errorf("API returned failure status")
} }
// 空列表是正常情况,不是错误
if len(response.Data.Coins) == 0 { if len(response.Data.Coins) == 0 {
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 // Set IsAvailable flag
@@ -103,7 +105,8 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
} }
if len(availableCoins) == 0 { 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) // Sort by Score descending (bubble sort)
@@ -145,10 +148,7 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
} }
} }
if len(symbols) == 0 { // Empty list is normal - just return empty slice, not an error
return nil, fmt.Errorf("no available coins")
}
return symbols, nil 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) // GetOITopPositions retrieves top OI increase positions (legacy compatibility)
func (c *Client) GetOITopPositions() ([]OIPosition, error) { func (c *Client) GetOITopPositions() ([]OIPosition, error) {
data, err := c.GetOIRanking("1h", 20) positions, _, err := c.fetchOIRanking("top", "1h", 20)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return data.TopPositions, nil return positions, nil
} }
// GetOITopSymbols retrieves OI top coin symbol list // GetOITopSymbols retrieves OI top coin symbol list
@@ -129,6 +129,31 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
return symbols, nil return symbols, nil
} }
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
positions, _, err := c.fetchOIRanking("low", "1h", 20)
if err != nil {
return nil, err
}
return positions, nil
}
// GetOILowSymbols retrieves OI low coin symbol list
func (c *Client) GetOILowSymbols() ([]string, error) {
positions, err := c.GetOILowPositions()
if err != nil {
return nil, err
}
var symbols []string
for _, pos := range positions {
symbol := NormalizeSymbol(pos.Symbol)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// FormatOIRankingForAI formats OI ranking data for AI consumption // FormatOIRankingForAI formats OI ranking data for AI consumption
func FormatOIRankingForAI(data *OIRankingData, lang Language) string { func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
if data == nil { if data == nil {

8
railway.toml Normal file
View File

@@ -0,0 +1,8 @@
[build]
dockerfilePath = "Dockerfile.railway"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 60
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3

57
railway/start.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/bin/sh
set -e
# Railway 会设置 PORT 环境变量
export PORT=${PORT:-8080}
echo "🚀 Starting NOFX on port $PORT..."
# 生成加密密钥(如果没有设置)
if [ -z "$RSA_PRIVATE_KEY" ]; then
export RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null)
fi
if [ -z "$DATA_ENCRYPTION_KEY" ]; then
export DATA_ENCRYPTION_KEY=$(openssl rand -base64 32)
fi
# 生成 nginx 配置
cat > /etc/nginx/http.d/default.conf << NGINX_EOF
server {
listen $PORT;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8081/api/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location /health {
return 200 'OK';
add_header Content-Type text/plain;
}
}
NGINX_EOF
# 启动后端(端口 8081
API_SERVER_PORT=8081 /app/nofx &
sleep 2
# 启动 nginx后台
nginx
echo "✅ NOFX started successfully"
# 保持容器运行
tail -f /dev/null

View File

@@ -7,6 +7,7 @@ import (
"nofx/store" "nofx/store"
"os" "os"
"path/filepath" "path/filepath"
"time"
) )
func main() { func main() {
@@ -83,7 +84,7 @@ func main() {
filledOrders++ filledOrders++
// 检查 filled_at // 检查 filled_at
if !order.FilledAt.IsZero() { if order.FilledAt > 0 {
withFilledAt++ withFilledAt++
} else { } else {
missingFilledAt++ missingFilledAt++
@@ -119,8 +120,8 @@ func main() {
} }
filledAtStr := "N/A" filledAtStr := "N/A"
if !order.FilledAt.IsZero() { if order.FilledAt > 0 {
filledAtStr = order.FilledAt.Format("01-02 15:04") filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
} }
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n", fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",

View File

@@ -0,0 +1,168 @@
//go:build ignore
// Test script to verify Lighter API authentication
// Run: go run scripts/test_lighter_orders.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
lighterClient "github.com/elliottech/lighter-go/client"
lighterHTTP "github.com/elliottech/lighter-go/client/http"
)
func main() {
// Configuration - update these values
walletAddr := os.Getenv("LIGHTER_WALLET")
apiKeyPrivateKey := os.Getenv("LIGHTER_API_KEY")
if walletAddr == "" || apiKeyPrivateKey == "" {
fmt.Println("Usage: LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go run scripts/test_lighter_orders.go")
fmt.Println("Environment variables required:")
fmt.Println(" LIGHTER_WALLET - Ethereum wallet address")
fmt.Println(" LIGHTER_API_KEY - API key private key (40 bytes hex)")
os.Exit(1)
}
fmt.Println("=== Lighter API Test ===")
fmt.Printf("Wallet: %s\n\n", walletAddr)
baseURL := "https://mainnet.zklighter.elliot.ai"
chainID := uint32(304)
client := &http.Client{Timeout: 30 * time.Second}
// Step 1: Get account info (no auth required)
fmt.Println("1. Getting account info...")
accountIndex, err := getAccountIndex(client, baseURL, walletAddr)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
fmt.Printf(" OK: account_index = %d\n\n", accountIndex)
// Step 2: Create TxClient and generate auth token
fmt.Println("2. Creating TxClient and generating auth token...")
httpClient := lighterHTTP.NewClient(baseURL)
txClient, err := lighterClient.NewTxClient(httpClient, apiKeyPrivateKey, accountIndex, 0, chainID)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
authToken, err := txClient.GetAuthToken(time.Now().Add(1 * time.Hour))
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
fmt.Printf(" OK: auth token generated\n\n")
// Step 3: Test GetActiveOrders with auth query parameter (NEW method)
fmt.Println("3. Testing GetActiveOrders with auth query parameter (FIXED)...")
encodedAuth := url.QueryEscape(authToken)
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
baseURL, accountIndex, encodedAuth)
resp, err := client.Get(endpoint)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if code, ok := result["code"].(float64); ok && code == 200 {
orders := result["orders"].([]interface{})
fmt.Printf(" OK: Retrieved %d orders\n", len(orders))
if len(orders) > 0 {
fmt.Println(" Sample orders:")
for i, o := range orders {
if i >= 3 {
fmt.Printf(" ... and %d more\n", len(orders)-3)
break
}
order := o.(map[string]interface{})
fmt.Printf(" - ID: %v, Price: %v, Side: %v\n",
order["order_id"], order["price"], order["is_ask"])
}
}
} else {
fmt.Printf(" FAILED: %s\n", string(body))
fmt.Println("\n Possible causes:")
fmt.Println(" - API key not registered on-chain")
fmt.Println(" - API key private key incorrect")
fmt.Println(" - Account index mismatch")
os.Exit(1)
}
// Step 4: Test GetActiveOrders with Authorization header (OLD method - for comparison)
fmt.Println("\n4. Testing GetActiveOrders with Authorization header (OLD method)...")
endpoint2 := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0",
baseURL, accountIndex)
req, _ := http.NewRequest("GET", endpoint2, nil)
req.Header.Set("Authorization", authToken)
req.Header.Set("Content-Type", "application/json")
resp2, err := client.Do(req)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
} else {
defer resp2.Body.Close()
body2, _ := io.ReadAll(resp2.Body)
var result2 map[string]interface{}
json.Unmarshal(body2, &result2)
if code, ok := result2["code"].(float64); ok && code == 200 {
orders := result2["orders"].([]interface{})
fmt.Printf(" OK: Retrieved %d orders (both methods work!)\n", len(orders))
} else {
fmt.Printf(" FAILED: %s\n", string(body2))
fmt.Println(" ^ This is expected - Authorization header doesn't work consistently")
}
}
fmt.Println("\n=== TEST COMPLETE ===")
fmt.Println("If test 3 passed, the fix is working correctly.")
}
func getAccountIndex(client *http.Client, baseURL, walletAddr string) (int64, error) {
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
resp, err := client.Get(endpoint)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Accounts []struct {
AccountIndex int64 `json:"account_index"`
} `json:"accounts"`
SubAccounts []struct {
AccountIndex int64 `json:"account_index"`
} `json:"sub_accounts"`
}
if err := json.Unmarshal(body, &result); err != nil {
return 0, fmt.Errorf("failed to parse: %w", err)
}
if len(result.Accounts) > 0 {
return result.Accounts[0].AccountIndex, nil
}
if len(result.SubAccounts) > 0 {
return result.SubAccounts[0].AccountIndex, nil
}
return 0, fmt.Errorf("no account found")
}

View File

@@ -149,7 +149,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"enabled": enabled, "enabled": enabled,
"custom_api_url": customAPIURL, "custom_api_url": customAPIURL,
"custom_model_name": customModelName, "custom_model_name": customModelName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
} }
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString) // If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
if apiKey != "" { if apiKey != "" {
@@ -167,7 +167,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"enabled": enabled, "enabled": enabled,
"custom_api_url": customAPIURL, "custom_api_url": customAPIURL,
"custom_model_name": customModelName, "custom_model_name": customModelName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
} }
if apiKey != "" { if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey) updates["api_key"] = crypto.EncryptedString(apiKey)

View File

@@ -147,7 +147,7 @@ func (BacktestCheckpoint) TableName() string {
type BacktestEquity struct { type BacktestEquity struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"` RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
TS int64 `gorm:"column:ts;not null;index:idx_backtest_equity_run_ts"` TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_equity_run_ts"`
Equity float64 `gorm:"column:equity;not null"` Equity float64 `gorm:"column:equity;not null"`
Available float64 `gorm:"column:available;not null"` Available float64 `gorm:"column:available;not null"`
PnL float64 `gorm:"column:pnl;not null"` PnL float64 `gorm:"column:pnl;not null"`
@@ -164,7 +164,7 @@ func (BacktestEquity) TableName() string {
type BacktestTrade struct { type BacktestTrade struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"` RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
TS int64 `gorm:"column:ts;not null;index:idx_backtest_trades_run_ts"` TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_trades_run_ts"`
Symbol string `gorm:"column:symbol;not null"` Symbol string `gorm:"column:symbol;not null"`
Action string `gorm:"column:action;not null"` Action string `gorm:"column:action;not null"`
Side string `gorm:"column:side;default:''"` Side string `gorm:"column:side;default:''"`
@@ -217,7 +217,10 @@ func (s *BacktestStore) initTables() error {
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists) s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
if tableExists > 0 { if tableExists > 0 {
// Tables exist - just ensure indexes exist // Tables exist - fix column types and ensure indexes exist
// Fix ts column type from INTEGER to BIGINT (timestamps in milliseconds exceed int4 max)
s.db.Exec(`ALTER TABLE backtest_equity ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`ALTER TABLE backtest_trades ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`)

View File

@@ -53,7 +53,9 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error {
snapshot.Timestamp = snapshot.Timestamp.UTC() 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 fmt.Errorf("failed to save equity snapshot: %w", err)
} }
return nil return nil

View File

@@ -17,27 +17,28 @@ type ExchangeStore struct {
// Exchange exchange configuration // Exchange exchange configuration
type Exchange struct { type Exchange struct {
ID string `gorm:"primaryKey" json:"id"` ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"` AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"` UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex" Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"` Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"` APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"` SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"` Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"` Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"` HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"` HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"` AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"` AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"` LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"` LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"` LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"` LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func (Exchange) TableName() string { return "exchanges" } func (Exchange) TableName() string { return "exchanges" }
@@ -173,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
return "Aster DEX", "dex" return "Aster DEX", "dex"
case "lighter": case "lighter":
return "LIGHTER DEX", "dex" return "LIGHTER DEX", "dex"
case "indodax":
return "Indodax", "cex"
default: default:
return exchangeType + " Exchange", "cex" return exchangeType + " Exchange", "cex"
} }
@@ -181,7 +184,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID // Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool, func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) { lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String() id := uuid.New().String()
@@ -207,6 +211,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
Passphrase: crypto.EncryptedString(passphrase), Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet, Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr, HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser, AsterUser: asterUser,
AsterSigner: asterSigner, AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey), AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
@@ -224,19 +229,21 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID // Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool, func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error { hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
updates := map[string]interface{}{ updates := map[string]interface{}{
"enabled": enabled, "enabled": enabled,
"testnet": testnet, "testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr, "hyperliquid_wallet_addr": hyperliquidWalletAddr,
"aster_user": asterUser, "hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_signer": asterSigner, "aster_user": asterUser,
"lighter_wallet_addr": lighterWalletAddr, "aster_signer": asterSigner,
"lighter_api_key_index": lighterApiKeyIndex, "lighter_wallet_addr": lighterWalletAddr,
"updated_at": time.Now(), "lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
} }
// Only update encrypted fields if not empty // Only update encrypted fields if not empty
@@ -275,7 +282,7 @@ func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error
Where("id = ? AND user_id = ?", id, userID). Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"account_name": accountName, "account_name": accountName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}) })
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error
@@ -307,7 +314,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
// Check if this is an old-style ID (exchange type as ID) // Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" { if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet, _, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0) hyperliquidWalletAddr, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err return err
} }

View File

@@ -2,6 +2,7 @@ package store
import ( import (
"fmt" "fmt"
"time"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -21,6 +22,10 @@ func DB() *gorm.DB {
func InitGorm(dbPath string) (*gorm.DB, error) { func InitGorm(dbPath string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
NowFunc: func() time.Time {
return time.Now().UTC()
},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open SQLite database: %w", err) return nil, fmt.Errorf("failed to open SQLite database: %w", err)
@@ -53,6 +58,10 @@ func InitGormPostgres(host string, port int, user, password, dbname, sslmode str
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
NowFunc: func() time.Time {
return time.Now().UTC()
},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err) return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)

594
store/grid.go Normal file
View File

@@ -0,0 +1,594 @@
package store
import (
"fmt"
"time"
"gorm.io/gorm"
)
// ==================== Grid Store Models ====================
// These models mirror the grid package types but are defined here
// to avoid import cycles between store and grid packages.
// GridConfigModel GORM model for grid_configs table
type GridConfigModel struct {
ID string `json:"id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"index"`
TraderID string `json:"trader_id" gorm:"index"`
Symbol string `json:"symbol" gorm:"not null"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
GridCount int `json:"grid_count" gorm:"default:10"`
TotalInvestment float64 `json:"total_investment" gorm:"not null"`
Leverage int `json:"leverage" gorm:"default:5"`
UpperPrice float64 `json:"upper_price"`
LowerPrice float64 `json:"lower_price"`
UseATRBounds bool `json:"use_atr_bounds" gorm:"default:true"`
ATRMultiplier float64 `json:"atr_multiplier" gorm:"default:2.0"`
Distribution string `json:"distribution" gorm:"default:gaussian"`
MaxDrawdownPct float64 `json:"max_drawdown_pct" gorm:"default:15.0"`
StopLossPct float64 `json:"stop_loss_pct" gorm:"default:5.0"`
DailyLossLimitPct float64 `json:"daily_loss_limit_pct" gorm:"default:10"`
MaxPositionSizePct float64 `json:"max_position_size_pct" gorm:"default:30"`
RegimeCheckInterval int `json:"regime_check_interval" gorm:"default:30"`
AutoPauseOnTrend bool `json:"auto_pause_on_trend" gorm:"default:true"`
MinRangingScore int `json:"min_ranging_score" gorm:"default:60"`
TrendResumeThreshold int `json:"trend_resume_threshold" gorm:"default:70"`
// Box indicator periods (1h candles)
ShortBoxPeriod int `json:"short_box_period" gorm:"default:72"` // 3 days
MidBoxPeriod int `json:"mid_box_period" gorm:"default:240"` // 10 days
LongBoxPeriod int `json:"long_box_period" gorm:"default:500"` // 21 days
// Effective leverage limits by regime level
NarrowRegimeLeverage int `json:"narrow_regime_leverage" gorm:"default:2"`
StandardRegimeLeverage int `json:"standard_regime_leverage" gorm:"default:4"`
WideRegimeLeverage int `json:"wide_regime_leverage" gorm:"default:3"`
VolatileRegimeLeverage int `json:"volatile_regime_leverage" gorm:"default:2"`
// Position limits by regime level (percentage of total investment)
NarrowRegimePositionPct float64 `json:"narrow_regime_position_pct" gorm:"default:40"`
StandardRegimePositionPct float64 `json:"standard_regime_position_pct" gorm:"default:70"`
WideRegimePositionPct float64 `json:"wide_regime_position_pct" gorm:"default:60"`
VolatileRegimePositionPct float64 `json:"volatile_regime_position_pct" gorm:"default:40"`
OrderRefreshSec int `json:"order_refresh_sec" gorm:"default:300"`
UseMakerOnly bool `json:"use_maker_only" gorm:"default:true"`
SlippageTolerPct float64 `json:"slippage_toler_pct" gorm:"default:0.1"`
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 {
return "grid_configs"
}
// GridInstanceModel GORM model for grid_instances table
type GridInstanceModel struct {
ID string `json:"id" gorm:"primaryKey"`
ConfigID string `json:"config_id" gorm:"index;not null"`
Symbol string `json:"symbol" gorm:"not null"`
State string `json:"state" gorm:"not null"`
StartedAt time.Time `json:"started_at"`
StoppedAt *time.Time `json:"stopped_at,omitempty"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
CurrentUpperPrice float64 `json:"current_upper_price"`
CurrentLowerPrice float64 `json:"current_lower_price"`
CurrentGridSpacing float64 `json:"current_grid_spacing"`
ActiveLevelCount int `json:"active_level_count"`
CurrentRegime string `json:"current_regime"`
RegimeScore int `json:"regime_score"`
LastRegimeCheck time.Time `json:"last_regime_check"`
ConsecutiveTrending int `json:"consecutive_trending"`
// Current regime level (narrow/standard/wide/volatile/trending)
CurrentRegimeLevel string `json:"current_regime_level" gorm:"default:standard"`
// Box state
ShortBoxUpper float64 `json:"short_box_upper"`
ShortBoxLower float64 `json:"short_box_lower"`
MidBoxUpper float64 `json:"mid_box_upper"`
MidBoxLower float64 `json:"mid_box_lower"`
LongBoxUpper float64 `json:"long_box_upper"`
LongBoxLower float64 `json:"long_box_lower"`
// Breakout state
BreakoutLevel string `json:"breakout_level" gorm:"default:none"` // none/short/mid/long
BreakoutDirection string `json:"breakout_direction"` // up/down
BreakoutConfirmCount int `json:"breakout_confirm_count" gorm:"default:0"`
BreakoutStartTime time.Time `json:"breakout_start_time"`
// 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"`
WinningTrades int `json:"winning_trades" gorm:"default:0"`
MaxDrawdown float64 `json:"max_drawdown" gorm:"default:0"`
CurrentDrawdown float64 `json:"current_drawdown" gorm:"default:0"`
PeakEquity float64 `json:"peak_equity" gorm:"default:0"`
DailyProfit float64 `json:"daily_profit" gorm:"default:0"`
DailyLoss float64 `json:"daily_loss" gorm:"default:0"`
LastDailyReset time.Time `json:"last_daily_reset"`
}
func (GridInstanceModel) TableName() string {
return "grid_instances"
}
// GridLevelModel GORM model for grid_levels table
type GridLevelModel struct {
ID string `json:"id" gorm:"primaryKey"`
InstanceID string `json:"instance_id" gorm:"index;not null"`
LevelIndex int `json:"level_index" gorm:"not null"`
Price float64 `json:"price" gorm:"not null"`
State string `json:"state" gorm:"not null"`
Side string `json:"side"`
OrderID string `json:"order_id,omitempty"`
OrderPrice float64 `json:"order_price,omitempty"`
OrderQuantity float64 `json:"order_quantity,omitempty"`
OrderCreatedAt *time.Time `json:"order_created_at,omitempty"`
PositionSize float64 `json:"position_size,omitempty"`
PositionEntry float64 `json:"position_entry,omitempty"`
PositionOpenAt *time.Time `json:"position_open_at,omitempty"`
AllocationWeight float64 `json:"allocation_weight"`
AllocatedUSD float64 `json:"allocated_usd"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
func (GridLevelModel) TableName() string {
return "grid_levels"
}
// GridEventModel GORM model for grid_events table
type GridEventModel struct {
ID string `json:"id" gorm:"primaryKey"`
InstanceID string `json:"instance_id" gorm:"index;not null"`
LevelID string `json:"level_id,omitempty" gorm:"index"`
EventType string `json:"event_type" gorm:"not null"`
EventTime time.Time `json:"event_time" gorm:"autoCreateTime"`
Price float64 `json:"price,omitempty"`
Quantity float64 `json:"quantity,omitempty"`
Side string `json:"side,omitempty"`
PnL float64 `json:"pnl,omitempty"`
Fee float64 `json:"fee,omitempty"`
Message string `json:"message,omitempty"`
OldRegime string `json:"old_regime,omitempty"`
NewRegime string `json:"new_regime,omitempty"`
TriggerType string `json:"trigger_type,omitempty"`
RawData string `json:"raw_data,omitempty" gorm:"type:text"`
}
func (GridEventModel) TableName() string {
return "grid_events"
}
// GridRegimeAssessmentModel GORM model for grid_regime_assessments table
type GridRegimeAssessmentModel struct {
ID string `json:"id" gorm:"primaryKey"`
InstanceID string `json:"instance_id" gorm:"index;not null"`
AssessedAt time.Time `json:"assessed_at" gorm:"autoCreateTime"`
Regime string `json:"regime" gorm:"not null"`
Score int `json:"score" gorm:"not null"`
Confidence float64 `json:"confidence"`
BollingerSignal int `json:"bollinger_signal"`
EMASignal int `json:"ema_signal"`
MACDSignal int `json:"macd_signal"`
VolumeSignal int `json:"volume_signal"`
OISignal int `json:"oi_signal"`
FundingSignal int `json:"funding_signal"`
CandleSignal int `json:"candle_signal"`
ATR14 float64 `json:"atr14"`
BollingerWidth float64 `json:"bollinger_width"`
EMADistance float64 `json:"ema_distance"`
CurrentPrice float64 `json:"current_price"`
AIReasoning string `json:"ai_reasoning" gorm:"type:text"`
}
func (GridRegimeAssessmentModel) TableName() string {
return "grid_regime_assessments"
}
// ==================== Grid Store ====================
// GridStore provides database operations for grid trading
type GridStore struct {
db *gorm.DB
}
// NewGridStore creates a new grid store
func NewGridStore(db *gorm.DB) *GridStore {
return &GridStore{db: db}
}
// InitTables initializes grid-related tables
func (s *GridStore) InitTables() error {
// For PostgreSQL with existing tables, skip AutoMigrate to avoid type conflicts
if s.db.Dialector.Name() == "postgres" {
var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'grid_configs'`).Scan(&tableExists)
if tableExists > 0 {
// Tables exist, just ensure indexes
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_user_id ON grid_configs(user_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_trader_id ON grid_configs(trader_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_instances_config_id ON grid_instances(config_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_levels_instance_id ON grid_levels(instance_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_instance_id ON grid_events(instance_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_level_id ON grid_events(level_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_regime_assessments_instance_id ON grid_regime_assessments(instance_id)`)
return nil
}
}
// AutoMigrate all grid tables
if err := s.db.AutoMigrate(
&GridConfigModel{},
&GridInstanceModel{},
&GridLevelModel{},
&GridEventModel{},
&GridRegimeAssessmentModel{},
); err != nil {
return fmt.Errorf("failed to migrate grid tables: %w", err)
}
return nil
}
// ==================== Config Operations ====================
// SaveGridConfig saves or updates a grid configuration
func (s *GridStore) SaveGridConfig(config *GridConfigModel) error {
config.UpdatedAt = time.Now()
if config.CreatedAt.IsZero() {
config.CreatedAt = time.Now()
}
return s.db.Save(config).Error
}
// LoadGridConfig loads a grid configuration by ID
func (s *GridStore) LoadGridConfig(id string) (*GridConfigModel, error) {
var config GridConfigModel
err := s.db.Where("id = ?", id).First(&config).Error
if err != nil {
return nil, err
}
return &config, nil
}
// LoadGridConfigByTrader loads a grid configuration by trader ID
func (s *GridStore) LoadGridConfigByTrader(traderID string) (*GridConfigModel, error) {
var config GridConfigModel
err := s.db.Where("trader_id = ? AND is_active = true", traderID).First(&config).Error
if err != nil {
return nil, err
}
return &config, nil
}
// ListGridConfigs lists all grid configurations for a user
func (s *GridStore) ListGridConfigs(userID string) ([]GridConfigModel, error) {
var configs []GridConfigModel
err := s.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&configs).Error
if err != nil {
return nil, err
}
return configs, nil
}
// DeleteGridConfig deletes a grid configuration and all related data
func (s *GridStore) DeleteGridConfig(id string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// Get all instances for this config
var instances []GridInstanceModel
if err := tx.Where("config_id = ?", id).Find(&instances).Error; err != nil {
return err
}
// Delete related data for each instance
for _, instance := range instances {
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridLevelModel{}).Error; err != nil {
return err
}
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridEventModel{}).Error; err != nil {
return err
}
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridRegimeAssessmentModel{}).Error; err != nil {
return err
}
}
// Delete instances
if err := tx.Where("config_id = ?", id).Delete(&GridInstanceModel{}).Error; err != nil {
return err
}
// Delete config
return tx.Where("id = ?", id).Delete(&GridConfigModel{}).Error
})
}
// ==================== Instance Operations ====================
// SaveGridInstance saves or updates a grid instance
func (s *GridStore) SaveGridInstance(instance *GridInstanceModel) error {
instance.UpdatedAt = time.Now()
return s.db.Save(instance).Error
}
// LoadGridInstance loads a grid instance by config ID
func (s *GridStore) LoadGridInstance(configID string) (*GridInstanceModel, error) {
var instance GridInstanceModel
err := s.db.Where("config_id = ?", configID).
Order("started_at DESC").
First(&instance).Error
if err != nil {
return nil, err
}
return &instance, nil
}
// LoadGridInstanceByID loads a grid instance by ID
func (s *GridStore) LoadGridInstanceByID(id string) (*GridInstanceModel, error) {
var instance GridInstanceModel
err := s.db.Where("id = ?", id).First(&instance).Error
if err != nil {
return nil, err
}
return &instance, nil
}
// ListGridInstances lists all instances for a config
func (s *GridStore) ListGridInstances(configID string) ([]GridInstanceModel, error) {
var instances []GridInstanceModel
err := s.db.Where("config_id = ?", configID).
Order("started_at DESC").
Find(&instances).Error
if err != nil {
return nil, err
}
return instances, nil
}
// ==================== Level Operations ====================
// SaveGridLevel saves or updates a grid level
func (s *GridStore) SaveGridLevel(level *GridLevelModel) error {
level.UpdatedAt = time.Now()
return s.db.Save(level).Error
}
// SaveGridLevels saves multiple grid levels
func (s *GridStore) SaveGridLevels(levels []GridLevelModel) error {
if len(levels) == 0 {
return nil
}
now := time.Now()
for i := range levels {
levels[i].UpdatedAt = now
}
return s.db.Save(&levels).Error
}
// LoadGridLevels loads all levels for an instance
func (s *GridStore) LoadGridLevels(instanceID string) ([]GridLevelModel, error) {
var levels []GridLevelModel
err := s.db.Where("instance_id = ?", instanceID).
Order("level_index ASC").
Find(&levels).Error
if err != nil {
return nil, err
}
return levels, nil
}
// DeleteGridLevels deletes all levels for an instance
func (s *GridStore) DeleteGridLevels(instanceID string) error {
return s.db.Where("instance_id = ?", instanceID).Delete(&GridLevelModel{}).Error
}
// ==================== Event Operations ====================
// SaveGridEvent saves a grid event
func (s *GridStore) SaveGridEvent(event *GridEventModel) error {
if event.EventTime.IsZero() {
event.EventTime = time.Now()
}
return s.db.Create(event).Error
}
// LoadRecentGridEvents loads recent events for an instance
func (s *GridStore) LoadRecentGridEvents(instanceID string, limit int) ([]GridEventModel, error) {
var events []GridEventModel
query := s.db.Where("instance_id = ?", instanceID).
Order("event_time DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&events).Error
if err != nil {
return nil, err
}
return events, nil
}
// LoadGridEventsByType loads events of a specific type
func (s *GridStore) LoadGridEventsByType(instanceID, eventType string, limit int) ([]GridEventModel, error) {
var events []GridEventModel
query := s.db.Where("instance_id = ? AND event_type = ?", instanceID, eventType).
Order("event_time DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&events).Error
if err != nil {
return nil, err
}
return events, nil
}
// CountGridEvents counts events for an instance
func (s *GridStore) CountGridEvents(instanceID string) (int64, error) {
var count int64
err := s.db.Model(&GridEventModel{}).
Where("instance_id = ?", instanceID).
Count(&count).Error
return count, err
}
// ==================== Regime Assessment Operations ====================
// SaveGridRegimeAssessment saves a regime assessment
func (s *GridStore) SaveGridRegimeAssessment(assessment *GridRegimeAssessmentModel) error {
if assessment.AssessedAt.IsZero() {
assessment.AssessedAt = time.Now()
}
return s.db.Create(assessment).Error
}
// LoadLatestGridRegime loads the latest regime assessment
func (s *GridStore) LoadLatestGridRegime(instanceID string) (*GridRegimeAssessmentModel, error) {
var assessment GridRegimeAssessmentModel
err := s.db.Where("instance_id = ?", instanceID).
Order("assessed_at DESC").
First(&assessment).Error
if err != nil {
return nil, err
}
return &assessment, nil
}
// LoadGridRegimeHistory loads regime assessment history
func (s *GridStore) LoadGridRegimeHistory(instanceID string, limit int) ([]GridRegimeAssessmentModel, error) {
var assessments []GridRegimeAssessmentModel
query := s.db.Where("instance_id = ?", instanceID).
Order("assessed_at DESC")
if limit > 0 {
query = query.Limit(limit)
}
err := query.Find(&assessments).Error
if err != nil {
return nil, err
}
return assessments, nil
}
// ==================== Statistics Operations ====================
// GetGridInstanceStatistics returns statistics for an instance
func (s *GridStore) GetGridInstanceStatistics(instanceID string) (map[string]interface{}, error) {
var instance GridInstanceModel
if err := s.db.Where("id = ?", instanceID).First(&instance).Error; err != nil {
return nil, err
}
// Count events by type
var eventCounts []struct {
EventType string
Count int64
}
s.db.Model(&GridEventModel{}).
Select("event_type, count(*) as count").
Where("instance_id = ?", instanceID).
Group("event_type").
Find(&eventCounts)
eventCountMap := make(map[string]int64)
for _, ec := range eventCounts {
eventCountMap[ec.EventType] = ec.Count
}
// Get latest regime
var latestRegime GridRegimeAssessmentModel
s.db.Where("instance_id = ?", instanceID).
Order("assessed_at DESC").
First(&latestRegime)
winRate := 0.0
if instance.TotalTrades > 0 {
winRate = float64(instance.WinningTrades) / float64(instance.TotalTrades) * 100
}
return map[string]interface{}{
"instance_id": instance.ID,
"state": instance.State,
"started_at": instance.StartedAt,
"stopped_at": instance.StoppedAt,
"total_profit": instance.TotalProfit,
"total_fees": instance.TotalFees,
"total_trades": instance.TotalTrades,
"winning_trades": instance.WinningTrades,
"win_rate": winRate,
"max_drawdown": instance.MaxDrawdown,
"current_drawdown": instance.CurrentDrawdown,
"peak_equity": instance.PeakEquity,
"active_level_count": instance.ActiveLevelCount,
"current_regime": instance.CurrentRegime,
"regime_score": instance.RegimeScore,
"event_counts": eventCountMap,
"latest_regime_score": latestRegime.Score,
}, nil
}
// GetGridPerformanceMetrics returns performance metrics for a time period
func (s *GridStore) GetGridPerformanceMetrics(instanceID string, from, to time.Time) (map[string]interface{}, error) {
// Count trades in period
var tradeCounts struct {
TotalFills int64
BuyFills int64
SellFills int64
}
s.db.Model(&GridEventModel{}).
Select("count(*) as total_fills, "+
"sum(case when side = 'buy' then 1 else 0 end) as buy_fills, "+
"sum(case when side = 'sell' then 1 else 0 end) as sell_fills").
Where("instance_id = ? AND event_type = 'order_filled' AND event_time BETWEEN ? AND ?",
instanceID, from, to).
Scan(&tradeCounts)
// Sum profit/loss
var pnlSum struct {
TotalPnL float64
TotalFee float64
}
s.db.Model(&GridEventModel{}).
Select("coalesce(sum(pnl), 0) as total_pnl, coalesce(sum(fee), 0) as total_fee").
Where("instance_id = ? AND event_time BETWEEN ? AND ?", instanceID, from, to).
Scan(&pnlSum)
// Count regime changes
var regimeChanges int64
s.db.Model(&GridEventModel{}).
Where("instance_id = ? AND event_type = 'regime_change' AND event_time BETWEEN ? AND ?",
instanceID, from, to).
Count(&regimeChanges)
return map[string]interface{}{
"period_start": from,
"period_end": to,
"total_fills": tradeCounts.TotalFills,
"buy_fills": tradeCounts.BuyFills,
"sell_fills": tradeCounts.SellFills,
"total_pnl": pnlSum.TotalPnL,
"total_fees": pnlSum.TotalFee,
"net_pnl": pnlSum.TotalPnL - pnlSum.TotalFee,
"regime_changes": regimeChanges,
}, nil
}

View File

@@ -2,43 +2,44 @@ package store
import ( import (
"fmt" "fmt"
"strings" "strconv"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// TraderOrder order record // TraderOrder order record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderOrder struct { type TraderOrder struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"` ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"` ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"` Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"` PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
Type string `gorm:"column:type;not null" json:"type"` Type string `gorm:"column:type;not null" json:"type"`
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"` TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
Price float64 `gorm:"column:price;default:0" json:"price"` Price float64 `gorm:"column:price;default:0" json:"price"`
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"` StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"` Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"` FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"` AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
Commission float64 `gorm:"column:commission;default:0" json:"commission"` Commission float64 `gorm:"column:commission;default:0" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"` CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"` Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"` ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"` ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"` WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"` PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"` OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"` RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"` FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
} }
// TableName returns the table name for TraderOrder // TableName returns the table name for TraderOrder
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
} }
// TraderFill trade record // TraderFill trade record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderFill struct { type TraderFill struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"` OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"` ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"` ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"` Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
Price float64 `gorm:"column:price;not null" json:"price"` Price float64 `gorm:"column:price;not null" json:"price"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"` QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
Commission float64 `gorm:"column:commission;not null" json:"commission"` Commission float64 `gorm:"column:commission;not null" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"` CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"` IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
} }
// TableName returns the table name for TraderFill // TableName returns the table name for TraderFill
@@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error {
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col)) s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col))
} }
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
// Check if column is still timestamp type before migrating
timestampColumns := []struct{ table, col string }{
{"trader_orders", "created_at"},
{"trader_orders", "updated_at"},
{"trader_orders", "filled_at"},
{"trader_fills", "created_at"},
}
for _, c := range timestampColumns {
var dataType string
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.col).Scan(&dataType)
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
// Convert timestamp to Unix milliseconds (bigint)
s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col))
}
}
// Ensure indexes exist // Ensure indexes exist
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`)
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`)
@@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr
"filled_quantity": filledQty, "filled_quantity": filledQty,
"avg_fill_price": avgPrice, "avg_fill_price": avgPrice,
"commission": commission, "commission": commission,
"updated_at": time.Now().UTC().UnixMilli(),
} }
if status == "FILLED" { if status == "FILLED" {
updates["filled_at"] = time.Now() updates["filled_at"] = time.Now().UTC().UnixMilli()
} }
return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error
@@ -217,6 +237,27 @@ func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder
return orders, nil return orders, nil
} }
// GetTraderOrdersFiltered gets trader's order list with optional symbol and status filters
func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, status string, limit int) ([]*TraderOrder, error) {
var orders []*TraderOrder
query := s.db.Where("trader_id = ?", traderID)
if symbol != "" {
query = query.Where("symbol = ?", symbol)
}
if status != "" {
query = query.Where("status = ?", status)
}
err := query.Order("created_at DESC").
Limit(limit).
Find(&orders).Error
if err != nil {
return nil, fmt.Errorf("failed to query orders: %w", err)
}
return orders, nil
}
// GetOrderFills gets order's fill records // GetOrderFills gets order's fill records
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) { func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
var fills []*TraderFill var fills []*TraderFill
@@ -324,29 +365,59 @@ func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange // GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) { func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
type symbolMaxID struct { type symbolTradeID struct {
Symbol string Symbol string
MaxTradeID int64 ExchangeTradeID string
} }
var results []symbolMaxID var results []symbolTradeID
// Query all trade IDs grouped by symbol, find max in Go to avoid database-specific CAST issues
// (PostgreSQL INTEGER is 32-bit, can't handle Binance trade IDs > 2.1B)
err := s.db.Model(&TraderFill{}). err := s.db.Model(&TraderFill{}).
Select("symbol, MAX(CAST(exchange_trade_id AS INTEGER)) as max_trade_id"). Select("symbol, exchange_trade_id").
Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID). Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID).
Group("symbol").
Find(&results).Error Find(&results).Error
if err != nil { if err != nil {
// If CAST fails (non-numeric trade IDs), fallback to string comparison return nil, fmt.Errorf("failed to query trade IDs: %w", err)
if strings.Contains(err.Error(), "CAST") || strings.Contains(err.Error(), "invalid") {
return make(map[string]int64), nil
}
return nil, fmt.Errorf("failed to query max trade IDs: %w", err)
} }
// Find max trade ID per symbol in Go (handles 64-bit integers properly)
result := make(map[string]int64) result := make(map[string]int64)
for _, r := range results { for _, r := range results {
result[r.Symbol] = r.MaxTradeID tradeID, err := strconv.ParseInt(r.ExchangeTradeID, 10, 64)
if err != nil {
continue // Skip non-numeric trade IDs
}
if tradeID > result[r.Symbol] {
result[r.Symbol] = tradeID
}
} }
return result, nil return result, nil
} }
// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange
// Used to recover sync state after service restart
func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) {
var fill TraderFill
err := s.db.Where("exchange_id = ?", exchangeID).
Order("created_at DESC").
First(&fill).Error
if err != nil {
return 0, err
}
return fill.CreatedAt, nil
}
// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms)
func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) {
var symbols []string
err := s.db.Model(&TraderFill{}).
Select("DISTINCT symbol").
Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs).
Pluck("symbol", &symbols).Error
if err != nil {
return nil, err
}
return symbols, nil
}

View File

@@ -3,12 +3,63 @@ package store
import ( import (
"fmt" "fmt"
"math" "math"
"strconv"
"strings" "strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision.
// For small prices (like meme coins), it preserves more decimal places.
// It detects the number of decimal places needed from the reference price(s).
func adaptivePriceRound(price float64, referencePrices ...float64) float64 {
if price == 0 {
return 0
}
// Find the minimum magnitude among all prices (including the price itself)
minMagnitude := math.Abs(price)
for _, ref := range referencePrices {
if ref > 0 && ref < minMagnitude {
minMagnitude = ref
}
}
// Determine decimal places needed based on price magnitude
// For price 0.000000541, we need ~15 decimal places
// For price 0.0001, we need ~8 decimal places
// For price 1.0, we need ~4 decimal places
var multiplier float64
switch {
case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB)
multiplier = 1e15 // 15 decimal places
case minMagnitude < 0.0001: // Very small (PEPE, FLOKI)
multiplier = 1e12 // 12 decimal places
case minMagnitude < 0.01: // Small
multiplier = 1e10 // 10 decimal places
case minMagnitude < 1: // Medium
multiplier = 1e8 // 8 decimal places
default: // Large
multiplier = 1e6 // 6 decimal places
}
return math.Round(price*multiplier) / multiplier
}
// getPriceDecimalPlaces returns the number of decimal places in a price string
func getPriceDecimalPlaces(price float64) int {
if price == 0 {
return 0
}
s := strconv.FormatFloat(price, 'f', -1, 64)
idx := strings.Index(s, ".")
if idx == -1 {
return 0
}
return len(s) - idx - 1
}
// TraderStats trading statistics metrics // TraderStats trading statistics metrics
type TraderStats struct { type TraderStats struct {
TotalTrades int `json:"total_trades"` TotalTrades int `json:"total_trades"`
@@ -25,30 +76,31 @@ type TraderStats struct {
} }
// TraderPosition position record // TraderPosition position record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderPosition struct { type TraderPosition struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"` ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"` Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"` EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"` EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"` EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
EntryTime time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"` ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"` ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
Fee float64 `gorm:"column:fee;default:0" json:"fee"` Fee float64 `gorm:"column:fee;default:0" json:"fee"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"` Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"` Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"` CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
Source string `gorm:"column:source;default:system" json:"source"` Source string `gorm:"column:source;default:system" json:"source"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
} }
// TableName returns the table name // TableName returns the table name
@@ -78,6 +130,18 @@ func (s *PositionStore) InitTables() error {
var tableExists int64 var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists) s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
if tableExists > 0 { if tableExists > 0 {
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
// Check if column is still timestamp type before migrating
timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"}
for _, col := range timestampColumns {
var dataType string
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType)
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
// Convert timestamp to Unix milliseconds (bigint)
s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col))
}
}
// Just ensure index exists // Just ensure index exists
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`)
return nil return nil
@@ -115,15 +179,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
// ClosePosition closes position // ClosePosition closes position
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error { func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
now := time.Now() nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": now, "exit_time": nowMs,
"realized_pnl": realizedPnL, "realized_pnl": realizedPnL,
"fee": fee, "fee": fee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": nowMs,
}).Error }).Error
} }
@@ -142,18 +207,22 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000 newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000 newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
newEntryPrice = math.Round(newEntryPrice*100) / 100 // Use adaptive precision based on price magnitude (for meme coins with very small prices)
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
newFee := pos.Fee + addFee newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"quantity": newQty, "quantity": newQty,
"entry_quantity": newEntryQty, "entry_quantity": newEntryQty,
"entry_price": newEntryPrice, "entry_price": newEntryPrice,
"fee": newFee, "fee": newFee,
"updated_at": nowMs,
}).Error }).Error
} }
// ReducePositionQuantity reduces position quantity for partial close // ReducePositionQuantity reduces position quantity for partial close
// If quantity reaches 0 (or near 0), automatically closes the position
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error { func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
var pos TraderPosition var pos TraderPosition
if err := s.db.First(&pos, id).Error; err != nil { if err := s.db.First(&pos, id).Error; err != nil {
@@ -170,7 +239,26 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64 var newExitPrice float64
if newClosedQty > 0 { if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
newExitPrice = math.Round(newExitPrice*100) / 100 // Use adaptive precision based on price magnitude (for meme coins with very small prices)
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
}
nowMs := time.Now().UTC().UnixMilli()
// Check if position should be fully closed (quantity reduced to ~0)
const QUANTITY_TOLERANCE = 0.0001
if newQty <= QUANTITY_TOLERANCE {
// Auto-close: set status to CLOSED
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"quantity": 0,
"fee": newFee,
"exit_price": newExitPrice,
"realized_pnl": newPnL,
"status": "CLOSED",
"exit_time": nowMs,
"close_reason": "sync",
"updated_at": nowMs,
}).Error
} }
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
@@ -178,19 +266,23 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
"fee": newFee, "fee": newFee,
"exit_price": newExitPrice, "exit_price": newExitPrice,
"realized_pnl": newPnL, "realized_pnl": newPnL,
"updated_at": nowMs,
}).Error }).Error
} }
// UpdatePositionExchangeInfo updates exchange_id and exchange_type // UpdatePositionExchangeInfo updates exchange_id and exchange_type
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error { func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exchange_id": exchangeID, "exchange_id": exchangeID,
"exchange_type": exchangeType, "exchange_type": exchangeType,
"updated_at": nowMs,
}).Error }).Error
} }
// ClosePositionFully marks position as fully closed // ClosePositionFully marks position as fully closed
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error { // exitTimeMs is Unix milliseconds UTC
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error {
var pos TraderPosition var pos TraderPosition
if err := s.db.First(&pos, id).Error; err != nil { if err := s.db.First(&pos, id).Error; err != nil {
return fmt.Errorf("failed to get position: %w", err) return fmt.Errorf("failed to get position: %w", err)
@@ -205,11 +297,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
"quantity": quantity, "quantity": quantity,
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": exitTime, "exit_time": exitTimeMs,
"realized_pnl": totalRealizedPnL, "realized_pnl": totalRealizedPnL,
"fee": totalFee, "fee": totalFee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error }).Error
} }
@@ -432,13 +525,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
EntryPrice: pos.EntryPrice, EntryPrice: pos.EntryPrice,
ExitPrice: pos.ExitPrice, ExitPrice: pos.ExitPrice,
RealizedPnL: pos.RealizedPnL, RealizedPnL: pos.RealizedPnL,
EntryTime: pos.EntryTime.Unix(), EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
} }
if pos.ExitTime != nil { if pos.ExitTime > 0 {
t.ExitTime = pos.ExitTime.Unix() t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
duration := pos.ExitTime.Sub(pos.EntryTime) durationMs := pos.ExitTime - pos.EntryTime
t.HoldDuration = formatDuration(duration) t.HoldDuration = formatDurationMs(durationMs)
} }
if pos.EntryPrice > 0 { if pos.EntryPrice > 0 {
@@ -457,26 +550,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
// formatDuration formats a duration // formatDuration formats a duration
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < time.Minute { return formatDurationMs(d.Milliseconds())
return fmt.Sprintf("%ds", int(d.Seconds())) }
// formatDurationMs formats a duration in milliseconds
func formatDurationMs(ms int64) string {
seconds := ms / 1000
minutes := seconds / 60
hours := minutes / 60
days := hours / 24
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
} }
if d < time.Hour { if minutes < 60 {
return fmt.Sprintf("%dm", int(d.Minutes())) return fmt.Sprintf("%dm", minutes)
} }
if d < 24*time.Hour { if hours < 24 {
hours := int(d.Hours()) remainingMins := minutes % 60
minutes := int(d.Minutes()) % 60 if remainingMins == 0 {
if minutes == 0 {
return fmt.Sprintf("%dh", hours) return fmt.Sprintf("%dh", hours)
} }
return fmt.Sprintf("%dh%dm", hours, minutes) return fmt.Sprintf("%dh%dm", hours, remainingMins)
} }
days := int(d.Hours()) / 24 remainingHours := hours % 24
hours := int(d.Hours()) % 24 if remainingHours == 0 {
if hours == 0 {
return fmt.Sprintf("%dd", days) return fmt.Sprintf("%dd", days)
} }
return fmt.Sprintf("%dd%dh", days, hours) return fmt.Sprintf("%dd%dh", days, remainingHours)
} }
// calculateSharpeRatioFromPnls calculates Sharpe ratio // calculateSharpeRatioFromPnls calculates Sharpe ratio
@@ -566,8 +667,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
s.WinTrades++ s.WinTrades++
} }
if pos.ExitTime != nil { if pos.ExitTime > 0 {
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes() holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins) symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
} }
} }
@@ -615,7 +716,7 @@ type HoldingTimeStats struct {
// GetHoldingTimeStats analyzes performance by holding duration // GetHoldingTimeStats analyzes performance by holding duration
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) { func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
var positions []TraderPosition var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query holding time stats: %w", err) return nil, fmt.Errorf("failed to query holding time stats: %w", err)
} }
@@ -632,10 +733,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
} }
for _, pos := range positions { for _, pos := range positions {
if pos.ExitTime == nil { if pos.ExitTime == 0 {
continue continue
} }
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours() holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
var rangeKey string var rangeKey string
switch { switch {
@@ -792,12 +893,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
// Calculate average holding time // Calculate average holding time
var positions []TraderPosition var positions []TraderPosition
s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions) s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
if len(positions) > 0 { if len(positions) > 0 {
var totalMins float64 var totalMins float64
for _, pos := range positions { for _, pos := range positions {
if pos.ExitTime != nil { if pos.ExitTime > 0 {
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes() totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
} }
} }
summary.AvgHoldingMins = totalMins / float64(len(positions)) summary.AvgHoldingMins = totalMins / float64(len(positions))
@@ -917,6 +1018,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
} }
// ClosedPnLRecord represents a closed position record from exchange // ClosedPnLRecord represents a closed position record from exchange
// All time fields use int64 millisecond timestamps (UTC)
type ClosedPnLRecord struct { type ClosedPnLRecord struct {
Symbol string Symbol string
Side string Side string
@@ -926,8 +1028,8 @@ type ClosedPnLRecord struct {
RealizedPnL float64 RealizedPnL float64
Fee float64 Fee float64
Leverage int Leverage int
EntryTime time.Time EntryTime int64 // Unix milliseconds UTC
ExitTime time.Time ExitTime int64 // Unix milliseconds UTC
OrderID string OrderID string
CloseType string CloseType string
ExchangeID string ExchangeID string
@@ -954,7 +1056,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
exchangePositionID := record.ExchangeID exchangePositionID := record.ExchangeID
if exchangePositionID == "" { if exchangePositionID == "" {
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL) exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
} }
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID) exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
@@ -965,19 +1067,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return false, nil return false, nil
} }
exitTime := record.ExitTime exitTimeMs := record.ExitTime
entryTime := record.EntryTime entryTimeMs := record.EntryTime
if exitTime.IsZero() || exitTime.Year() < 2000 { // Validate timestamps (must be after year 2000 = ~946684800000 ms)
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
if exitTimeMs < minValidTime {
return false, nil return false, nil
} }
if entryTime.IsZero() || entryTime.Year() < 2000 { if entryTimeMs < minValidTime {
entryTime = exitTime entryTimeMs = exitTimeMs
} }
if entryTime.After(exitTime) { if entryTimeMs > exitTimeMs {
entryTime = exitTime entryTimeMs = exitTimeMs
} }
nowMs := time.Now().UTC().UnixMilli()
pos := &TraderPosition{ pos := &TraderPosition{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -988,16 +1093,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
Quantity: record.Quantity, Quantity: record.Quantity,
EntryQuantity: record.Quantity, EntryQuantity: record.Quantity,
EntryPrice: record.EntryPrice, EntryPrice: record.EntryPrice,
EntryTime: entryTime, EntryTime: entryTimeMs,
ExitPrice: record.ExitPrice, ExitPrice: record.ExitPrice,
ExitOrderID: record.OrderID, ExitOrderID: record.OrderID,
ExitTime: &exitTime, ExitTime: exitTimeMs,
RealizedPnL: record.RealizedPnL, RealizedPnL: record.RealizedPnL,
Fee: record.Fee, Fee: record.Fee,
Leverage: record.Leverage, Leverage: record.Leverage,
Status: "CLOSED", Status: "CLOSED",
CloseReason: record.CloseType, CloseReason: record.CloseType,
Source: "sync", Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
} }
err = s.db.Create(pos).Error err = s.db.Create(pos).Error
@@ -1011,21 +1118,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return true, nil return true, nil
} }
// GetLastClosedPositionTime gets the most recent exit time // GetLastClosedPositionTime gets the most recent exit time (Unix ms)
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) { func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
var pos TraderPosition var pos TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED"). err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
Order("exit_time DESC"). Order("exit_time DESC").
First(&pos).Error First(&pos).Error
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil { if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
return time.Now().Add(-30 * 24 * time.Hour), nil return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
} }
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err) return 0, fmt.Errorf("failed to get last closed position time: %w", err)
} }
return *pos.ExitTime, nil return pos.ExitTime, nil
} }
// CreateOpenPosition creates an open position // CreateOpenPosition creates an open position
@@ -1076,15 +1183,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
} }
// ClosePositionWithAccurateData closes a position with accurate data from exchange // ClosePositionWithAccurateData closes a position with accurate data from exchange
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error { // exitTimeMs is Unix milliseconds UTC
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error {
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": exitTime, "exit_time": exitTimeMs,
"realized_pnl": realizedPnL, "realized_pnl": realizedPnL,
"fee": fee, "fee": fee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error }).Error
} }

View File

@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
} }
// ProcessTrade processes a single trade and updates position accordingly // ProcessTrade processes a single trade and updates position accordingly
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) ProcessTrade( func (pb *PositionBuilder) ProcessTrade(
traderID, exchangeID, exchangeType, symbol, side, action string, traderID, exchangeID, exchangeType, symbol, side, action string,
quantity, price, fee, realizedPnL float64, quantity, price, fee, realizedPnL float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
if strings.HasPrefix(action, "open_") { if strings.HasPrefix(action, "open_") {
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID) return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
} else if strings.HasPrefix(action, "close_") { } else if strings.HasPrefix(action, "close_") {
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID) return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
} }
return nil return nil
} }
// handleOpen handles opening positions (create new or average into existing) // handleOpen handles opening positions (create new or average into existing)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleOpen( func (pb *PositionBuilder) handleOpen(
traderID, exchangeID, exchangeType, symbol, side string, traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee float64, quantity, price, fee float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
// Get existing OPEN position for (symbol, side) // Get existing OPEN position for (symbol, side)
@@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen(
return fmt.Errorf("failed to get open position: %w", err) return fmt.Errorf("failed to get open position: %w", err)
} }
nowMs := time.Now().UTC().UnixMilli()
if existing == nil { if existing == nil {
// Create new position // Create new position
position := &TraderPosition{ position := &TraderPosition{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
ExchangeType: exchangeType, ExchangeType: exchangeType,
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()), ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
Quantity: quantity, Quantity: quantity,
EntryPrice: price, EntryPrice: price,
EntryOrderID: orderID, EntryOrderID: orderID,
EntryTime: tradeTime, EntryTime: tradeTimeMs,
Leverage: 1, Leverage: 1,
Status: "OPEN", Status: "OPEN",
Source: "sync", Source: "sync",
Fee: fee, Fee: fee,
CreatedAt: time.Now(), CreatedAt: nowMs,
UpdatedAt: time.Now(), UpdatedAt: nowMs,
} }
return pb.positionStore.CreateOpenPosition(position) return pb.positionStore.CreateOpenPosition(position)
} }
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
} }
// handleClose handles closing positions (partial or full) // handleClose handles closing positions (partial or full)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleClose( func (pb *PositionBuilder) handleClose(
traderID, exchangeID, exchangeType, symbol, side string, traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee, realizedPnL float64, quantity, price, fee, realizedPnL float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
// Get OPEN position // Get OPEN position
@@ -143,7 +147,8 @@ func (pb *PositionBuilder) handleClose(
var finalExitPrice float64 var finalExitPrice float64
if totalClosed > 0 { if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
finalExitPrice = math.Round(finalExitPrice*100) / 100 // Use adaptive precision based on price magnitude (for meme coins with very small prices)
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
} else { } else {
finalExitPrice = price finalExitPrice = price
} }
@@ -161,7 +166,7 @@ func (pb *PositionBuilder) handleClose(
position.ID, position.ID,
finalExitPrice, finalExitPrice,
orderID, orderID,
tradeTime, tradeTimeMs,
totalPnL, totalPnL,
totalFee, totalFee,
"sync", "sync",

View File

@@ -28,6 +28,7 @@ type Store struct {
strategy *StrategyStore strategy *StrategyStore
equity *EquityStore equity *EquityStore
order *OrderStore order *OrderStore
grid *GridStore
mu sync.RWMutex mu sync.RWMutex
} }
@@ -156,6 +157,9 @@ func (s *Store) initTables() error {
if err := s.Order().InitTables(); err != nil { if err := s.Order().InitTables(); err != nil {
return fmt.Errorf("failed to initialize order tables: %w", err) return fmt.Errorf("failed to initialize order tables: %w", err)
} }
if err := s.Grid().InitTables(); err != nil {
return fmt.Errorf("failed to initialize grid tables: %w", err)
}
return nil return nil
} }
@@ -279,6 +283,16 @@ func (s *Store) Order() *OrderStore {
return s.order return s.order
} }
// Grid gets grid trading storage
func (s *Store) Grid() *GridStore {
s.mu.Lock()
defer s.mu.Unlock()
if s.grid == nil {
s.grid = NewGridStore(s.gdb)
}
return s.grid
}
// Close closes database connection // Close closes database connection
func (s *Store) Close() error { func (s *Store) Close() error {
if s.driver != nil { if s.driver != nil {

View File

@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
// StrategyConfig strategy configuration details (JSON structure) // StrategyConfig strategy configuration details (JSON structure)
type StrategyConfig struct { type StrategyConfig struct {
// Strategy type: "ai_trading" (default) or "grid_trading"
StrategyType string `json:"strategy_type,omitempty"`
// language setting: "zh" for Chinese, "en" for English // language setting: "zh" for Chinese, "en" for English
// This determines the language used for data formatting and prompt generation // This determines the language used for data formatting and prompt generation
Language string `json:"language,omitempty"` Language string `json:"language,omitempty"`
@@ -45,6 +48,43 @@ type StrategyConfig struct {
RiskControl RiskControlConfig `json:"risk_control"` RiskControl RiskControlConfig `json:"risk_control"`
// editable sections of System Prompt // editable sections of System Prompt
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"` PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
// Grid trading configuration (only used when StrategyType == "grid_trading")
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
}
// GridStrategyConfig grid trading specific configuration
type GridStrategyConfig struct {
// Trading pair (e.g., "BTCUSDT")
Symbol string `json:"symbol"`
// Number of grid levels (5-50)
GridCount int `json:"grid_count"`
// Total investment in USDT
TotalInvestment float64 `json:"total_investment"`
// Leverage (1-20)
Leverage int `json:"leverage"`
// Upper price boundary (0 = auto-calculate from ATR)
UpperPrice float64 `json:"upper_price"`
// Lower price boundary (0 = auto-calculate from ATR)
LowerPrice float64 `json:"lower_price"`
// Use ATR to auto-calculate bounds
UseATRBounds bool `json:"use_atr_bounds"`
// ATR multiplier for bound calculation (default 2.0)
ATRMultiplier float64 `json:"atr_multiplier"`
// Position distribution: "uniform" | "gaussian" | "pyramid"
Distribution string `json:"distribution"`
// Maximum drawdown percentage before emergency exit
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
// Stop loss percentage per position
StopLossPct float64 `json:"stop_loss_pct"`
// Daily loss limit percentage
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 // PromptSectionsConfig editable sections of System Prompt
@@ -61,7 +101,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration // CoinSourceConfig coin source configuration
type CoinSourceConfig struct { type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "mixed" // source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
SourceType string `json:"source_type"` SourceType string `json:"source_type"`
// static coin list (used when source_type = "static") // static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"` StaticCoins []string `json:"static_coins,omitempty"`
@@ -71,10 +111,20 @@ type CoinSourceConfig struct {
UseAI500 bool `json:"use_ai500"` UseAI500 bool `json:"use_ai500"`
// AI500 coin pool maximum count // AI500 coin pool maximum count
AI500Limit int `json:"ai500_limit,omitempty"` AI500Limit int `json:"ai500_limit,omitempty"`
// whether to use OI Top // whether to use OI Top (持仓增加榜,适合做多)
UseOITop bool `json:"use_oi_top"` UseOITop bool `json:"use_oi_top"`
// OI Top maximum count // OI Top maximum count
OITopLimit int `json:"oi_top_limit,omitempty"` OITopLimit int `json:"oi_top_limit,omitempty"`
// whether to use OI Low (持仓减少榜,适合做空)
UseOILow bool `json:"use_oi_low"`
// OI Low maximum count
OILowLimit int `json:"oi_low_limit,omitempty"`
// whether to use Hyperliquid All coins (all available perp pairs)
UseHyperAll bool `json:"use_hyper_all"`
// whether to use Hyperliquid Main coins (top N by 24h volume)
UseHyperMain bool `json:"use_hyper_main"`
// Hyperliquid Main maximum count (default 20)
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig // Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
} }
@@ -212,7 +262,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
UseAI500: true, UseAI500: true,
AI500Limit: 10, AI500Limit: 10,
UseOITop: false, UseOITop: false,
OITopLimit: 20, OITopLimit: 10,
UseOILow: false,
OILowLimit: 10,
}, },
Indicators: IndicatorConfig{ Indicators: IndicatorConfig{
Klines: KlineConfig{ Klines: KlineConfig{
@@ -328,7 +380,7 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
"config": strategy.Config, "config": strategy.Config,
"is_public": strategy.IsPublic, "is_public": strategy.IsPublic,
"config_visible": strategy.ConfigVisible, "config_visible": strategy.ConfigVisible,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}).Error }).Error
} }

View File

@@ -248,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
} }
return traders, nil return traders, nil
} }
// ListByExchangeID gets traders that use a specific exchange
func (s *TraderStore) ListByExchangeID(userID, exchangeID string) ([]*Trader, error) {
var traders []*Trader
err := s.db.Where("user_id = ? AND exchange_id = ?", userID, exchangeID).Find(&traders).Error
if err != nil {
return nil, err
}
return traders, nil
}
// ListByAIModelID gets traders that use a specific AI model
func (s *TraderStore) ListByAIModelID(userID, aiModelID string) ([]*Trader, error) {
var traders []*Trader
err := s.db.Where("user_id = ? AND ai_model_id = ?", userID, aiModelID).Find(&traders).Error
if err != nil {
return nil, err
}
return traders, nil
}

View File

@@ -1,8 +1,6 @@
package store package store
import ( import (
"crypto/rand"
"encoding/base32"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -18,24 +16,12 @@ type User struct {
ID string `gorm:"primaryKey" json:"id"` ID string `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"` Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
PasswordHash string `gorm:"column:password_hash;not null" json:"-"` PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
OTPSecret string `gorm:"column:otp_secret" json:"-"`
OTPVerified bool `gorm:"column:otp_verified;default:false" json:"otp_verified"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func (User) TableName() string { return "users" } func (User) TableName() string { return "users" }
// GenerateOTPSecret generates OTP secret
func GenerateOTPSecret() (string, error) {
secret := make([]byte, 20)
_, err := rand.Read(secret)
if err != nil {
return "", err
}
return base32.StdEncoding.EncodeToString(secret), nil
}
// NewUserStore creates a new UserStore // NewUserStore creates a new UserStore
func NewUserStore(db *gorm.DB) *UserStore { func NewUserStore(db *gorm.DB) *UserStore {
return &UserStore{db: db} return &UserStore{db: db}
@@ -54,9 +40,6 @@ func (s *UserStore) initTables() error {
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`) s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`) s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`) s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
// OTP columns (added later)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_secret TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_verified BOOLEAN DEFAULT FALSE`)
// Ensure unique index exists on email (don't care about the name) // Ensure unique index exists on email (don't care about the name)
var indexExists int64 var indexExists int64
@@ -114,16 +97,11 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
return userIDs, err return userIDs, err
} }
// UpdateOTPVerified updates OTP verification status
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
}
// UpdatePassword updates password // UpdatePassword updates password
func (s *UserStore) UpdatePassword(userID, passwordHash string) error { func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"password_hash": passwordHash, "password_hash": passwordHash,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}).Error }).Error
} }
@@ -138,7 +116,5 @@ func (s *UserStore) EnsureAdmin() error {
ID: "admin", ID: "admin",
Email: "admin@localhost", Email: "admin@localhost",
PasswordHash: "", PasswordHash: "",
OTPSecret: "",
OTPVerified: true,
}) })
} }

View File

@@ -1,4 +1,4 @@
package trader package aster
import ( import (
"fmt" "fmt"
@@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time) return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -68,7 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -85,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -96,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -113,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, IsMaker: false,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -125,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1,4 +1,4 @@
package trader package aster
import ( import (
"context" "context"
@@ -23,6 +23,7 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"nofx/trader/types"
) )
// AsterTrader Aster trading platform implementation // 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 // GetClosedPnL gets recent closing trades from Aster
// Note: Aster does NOT have a position history API, only trade history. // Note: Aster does NOT have a position history API, only trade history.
// This returns individual closing trades for real-time position closure detection. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) // Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord var records []types.ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue 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, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -1366,7 +1367,7 @@ type AsterTradeRecord struct {
} }
// GetTrades retrieves trade history from Aster // 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 { if limit <= 0 {
limit = 500 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) body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil { if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err) logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []TradeRecord{}, nil return []types.TradeRecord{}, nil
} }
var asterTrades []AsterTradeRecord var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil { if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err) logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []TradeRecord{}, nil return []types.TradeRecord{}, nil
} }
// Convert to unified TradeRecord format // Convert to unified TradeRecord format
var result []TradeRecord var result []types.TradeRecord
for _, at := range asterTrades { for _, at := range asterTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Qty, 64) qty, _ := strconv.ParseFloat(at.Qty, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{ trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: at.Side, Side: at.Side,
@@ -1407,10 +1408,201 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
result = append(result, trade) result = append(result, trade)
} }
return result, nil return result, nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var orders []struct {
OrderID int64 `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"`
PositionSide string `json:"positionSide"`
Type string `json:"type"`
Price string `json:"price"`
StopPrice string `json:"stopPrice"`
OrigQty string `json:"origQty"`
Status string `json:"status"`
}
if err := json.Unmarshal(body, &orders); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
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, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: order.Side,
PositionSide: order.PositionSide,
Type: order.Type,
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: order.Status,
})
}
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
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 {
return nil, fmt.Errorf("failed to format price: %w", err)
}
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Get precision information
prec, err := t.getPrecision(req.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get precision: %w", err)
}
// Convert to string with correct precision format
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
// Determine side
side := "BUY"
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
side = "SELL"
}
params := map[string]interface{}{
"symbol": req.Symbol,
"positionSide": "BOTH",
"type": "LIMIT",
"side": side,
"timeInForce": "GTC",
"quantity": qtyStr,
"price": priceStr,
}
// Add reduceOnly if specified
if req.ReduceOnly {
params["reduceOnly"] = "true"
}
body, err := t.request("POST", "/fapi/v3/order", params)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
// Extract order ID
orderID := ""
if id, ok := result["orderId"].(float64); ok {
orderID = fmt.Sprintf("%.0f", id)
} else if id, ok := result["orderId"].(string); ok {
orderID = id
}
// Extract client order ID
clientOrderID := ""
if cid, ok := result["clientOrderId"].(string); ok {
clientOrderID = cid
}
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: clientOrderID,
Symbol: req.Symbol,
Side: side,
Price: formattedPrice,
Quantity: formattedQty,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by order ID
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"symbol": symbol,
"orderId": orderID,
}
_, err := t.request("DELETE", "/fapi/v3/order", params)
if err != nil {
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
}
return nil
}
// GetOrderBook gets the order book for a symbol
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 20
}
// Aster uses public endpoint (no signature required)
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Bids [][]string `json:"bids"` // [[price, qty], ...]
Asks [][]string `json:"asks"` // [[price, qty], ...]
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Convert string arrays to float64 arrays
bids = make([][]float64, len(result.Bids))
for i, bid := range result.Bids {
if len(bid) >= 2 {
price, _ := strconv.ParseFloat(bid[0], 64)
qty, _ := strconv.ParseFloat(bid[1], 64)
bids[i] = []float64{price, qty}
}
}
asks = make([][]float64, len(result.Asks))
for i, ask := range result.Asks {
if len(ask) >= 2 {
price, _ := strconv.ParseFloat(ask[0], 64)
qty, _ := strconv.ParseFloat(ask[1], 64)
asks[i] = []float64{price, qty}
}
}
return bids, asks, nil
}

View File

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

View File

@@ -4,12 +4,22 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math" "math"
"nofx/kernel"
"nofx/experience" "nofx/experience"
"nofx/kernel"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/mcp" "nofx/mcp"
"nofx/store" "nofx/store"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -23,7 +33,7 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek" AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection // 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) ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration // Binance API configuration
@@ -35,19 +45,33 @@ type AutoTraderConfig struct {
BybitSecretKey string BybitSecretKey string
// OKX API configuration // OKX API configuration
OKXAPIKey string OKXAPIKey string
OKXSecretKey string OKXSecretKey string
OKXPassphrase string OKXPassphrase string
// Bitget API configuration // Bitget API configuration
BitgetAPIKey string BitgetAPIKey string
BitgetSecretKey string BitgetSecretKey string
BitgetPassphrase string BitgetPassphrase string
// Gate API configuration
GateAPIKey string
GateSecretKey string
// KuCoin API configuration
KuCoinAPIKey string
KuCoinSecretKey string
KuCoinPassphrase string
// Indodax API configuration
IndodaxAPIKey string
IndodaxSecretKey string
// Hyperliquid configuration // Hyperliquid configuration
HyperliquidPrivateKey string HyperliquidPrivateKey string
HyperliquidWalletAddr string HyperliquidWalletAddr string
HyperliquidTestnet bool HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration // Aster configuration
AsterUser string // Aster main wallet address AsterUser string // Aster main wallet address
@@ -103,9 +127,9 @@ type AutoTrader struct {
config AutoTraderConfig config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms) trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient mcpClient mcp.AIClient
store *store.Store // Data storage (decision records, etc.) store *store.Store // Data storage (decision records, etc.)
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration) strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
cycleNumber int // Current cycle number cycleNumber int // Current cycle number
initialBalance float64 initialBalance float64
dailyPnL float64 dailyPnL float64
customPrompt string // Custom trading strategy prompt customPrompt string // Custom trading strategy prompt
@@ -123,6 +147,7 @@ type AutoTrader struct {
peakPnLCacheMutex sync.RWMutex // Cache read-write lock peakPnLCacheMutex sync.RWMutex // Cache read-write lock
lastBalanceSyncTime time.Time // Last balance sync time lastBalanceSyncTime time.Time // Last balance sync time
userID string // User ID userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
} }
// NewAutoTrader creates an automatic trader // NewAutoTrader creates an automatic trader
@@ -223,25 +248,31 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
switch config.Exchange { switch config.Exchange {
case "binance": case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name) 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": case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name) logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx": case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name) 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": case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name) 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": case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) 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, config.HyperliquidUnifiedAcct)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err) return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
} }
case "aster": case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name) 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 { if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err) return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
} }
@@ -253,7 +284,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
} }
// Lighter only supports mainnet (testnet disabled) // Lighter only supports mainnet (testnet disabled)
trader, err = NewLighterTraderV2( trader, err = lighter.NewLighterTraderV2(
config.LighterWalletAddr, config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey, config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex, config.LighterAPIKeyIndex,
@@ -263,6 +294,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err) return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
} }
logger.Infof("✓ LIGHTER trader initialized successfully") logger.Infof("✓ LIGHTER trader initialized successfully")
case "indodax":
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
default: default:
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange) return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
} }
@@ -362,7 +396,7 @@ func (at *AutoTrader) Run() error {
// Start Lighter order sync if using Lighter exchange // Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" { 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) 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) logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
} }
@@ -370,7 +404,7 @@ func (at *AutoTrader) Run() error {
// Start Hyperliquid order sync if using Hyperliquid exchange // Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" { 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) 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) logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
} }
@@ -378,7 +412,7 @@ func (at *AutoTrader) Run() error {
// Start Bybit order sync if using Bybit exchange // Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" { 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) 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) logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
} }
@@ -386,7 +420,7 @@ func (at *AutoTrader) Run() error {
// Start OKX order sync if using OKX exchange // Start OKX order sync if using OKX exchange
if at.exchange == "okx" { 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) 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) logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
} }
@@ -394,7 +428,7 @@ func (at *AutoTrader) Run() error {
// Start Bitget order sync if using Bitget exchange // Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" { 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) 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) logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
} }
@@ -402,7 +436,7 @@ func (at *AutoTrader) Run() error {
// Start Aster order sync if using Aster exchange // Start Aster order sync if using Aster exchange
if at.exchange == "aster" { 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) 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) logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
} }
@@ -410,18 +444,50 @@ func (at *AutoTrader) Run() error {
// Start Binance order sync if using Binance exchange // Start Binance order sync if using Binance exchange
if at.exchange == "binance" { 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) 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) 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) ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop() defer ticker.Stop()
// Check if this is a grid trading strategy
isGridStrategy := at.IsGridStrategy()
if isGridStrategy {
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
if err := at.InitializeGrid(); err != nil {
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
return fmt.Errorf("grid initialization failed: %w", err)
}
}
// Execute immediately on first run // Execute immediately on first run
if err := at.runCycle(); err != nil { if isGridStrategy {
logger.Infof("❌ Execution failed: %v", err) if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
} }
for { for {
@@ -435,8 +501,14 @@ func (at *AutoTrader) Run() error {
select { select {
case <-ticker.C: case <-ticker.C:
if err := at.runCycle(); err != nil { if isGridStrategy {
logger.Infof("❌ Execution failed: %v", err) if err := at.RunGridCycle(); err != nil {
logger.Infof("❌ Grid execution failed: %v", err)
}
} else {
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
}
} }
case <-at.stopMonitorCh: case <-at.stopMonitorCh:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name) logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
@@ -512,8 +584,25 @@ func (at *AutoTrader) runCycle() error {
} }
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve) // Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
at.saveEquitySnapshot(ctx) 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)) logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins { for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
@@ -637,7 +726,7 @@ func (at *AutoTrader) runCycle() error {
TakeProfit: d.TakeProfit, TakeProfit: d.TakeProfit,
Confidence: d.Confidence, Confidence: d.Confidence,
Reasoning: d.Reasoning, Reasoning: d.Reasoning,
Timestamp: time.Now(), Timestamp: time.Now().UTC(),
Success: false, Success: false,
} }
@@ -744,8 +833,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Priority 1: Get from database (trader_positions table) - most accurate // Priority 1: Get from database (trader_positions table) - most accurate
if at.store != nil { if at.store != nil {
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil { if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
if !dbPos.EntryTime.IsZero() { if dbPos.EntryTime > 0 {
updateTime = dbPos.EntryTime.UnixMilli() updateTime = dbPos.EntryTime
} }
} }
} }
@@ -792,14 +881,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
} }
// 3. Use strategy engine to get candidate coins (must have strategy engine) // 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil { 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 // 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance totalPnL := totalEquity - at.initialBalance
@@ -1021,7 +1115,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
} }
// Get current price // Get current price
marketData, err := market.Get(decision.Symbol) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return err
} }
@@ -1138,7 +1232,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
} }
// Get current price // Get current price
marketData, err := market.Get(decision.Symbol) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return err
} }
@@ -1237,7 +1331,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
logger.Infof(" 🔄 Close long: %s", decision.Symbol) logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price // Get current price
marketData, err := market.Get(decision.Symbol) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return err
} }
@@ -1301,7 +1395,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
logger.Infof(" 🔄 Close short: %s", decision.Symbol) logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price // Get current price
marketData, err := market.Get(decision.Symbol) marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil { if err != nil {
return err return err
} }
@@ -1365,6 +1459,12 @@ func (at *AutoTrader) GetID() string {
return at.id return at.id
} }
// GetUnderlyingTrader returns the underlying Trader interface implementation
// This is used by grid trading and other components that need direct exchange access
func (at *AutoTrader) GetUnderlyingTrader() Trader {
return at.trader
}
// GetName gets trader name // GetName gets trader name
func (at *AutoTrader) GetName() string { func (at *AutoTrader) GetName() string {
return at.name return at.name
@@ -1471,7 +1571,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
isRunning := at.isRunning isRunning := at.isRunning
at.isRunningMutex.RUnlock() at.isRunningMutex.RUnlock()
return map[string]interface{}{ result := map[string]interface{}{
"trader_id": at.id, "trader_id": at.id,
"trader_name": at.name, "trader_name": at.name,
"ai_model": at.aiModel, "ai_model": at.aiModel,
@@ -1486,6 +1586,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
"last_reset_time": at.lastResetTime.Format(time.RFC3339), "last_reset_time": at.lastResetTime.Format(time.RFC3339),
"ai_provider": aiProvider, "ai_provider": aiProvider,
} }
// Add strategy info
if at.config.StrategyConfig != nil {
result["strategy_type"] = at.config.StrategyConfig.StrategyType
if at.config.StrategyConfig.GridConfig != nil {
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
}
}
return result
} }
// GetAccountInfo gets account information (for API) // GetAccountInfo gets account information (for API)
@@ -1881,7 +1991,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it // Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records // This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange { switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster": case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID) logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return return
} }
@@ -1967,6 +2077,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
switch action { switch action {
case "open_long", "open_short": case "open_long", "open_short":
// Open position: create new position record // Open position: create new position record
nowMs := time.Now().UTC().UnixMilli()
pos := &store.TraderPosition{ pos := &store.TraderPosition{
TraderID: at.id, TraderID: at.id,
ExchangeID: at.exchangeID, // Exchange account UUID ExchangeID: at.exchangeID, // Exchange account UUID
@@ -1976,9 +2087,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
Quantity: quantity, Quantity: quantity,
EntryPrice: price, EntryPrice: price,
EntryOrderID: orderID, EntryOrderID: orderID,
EntryTime: time.Now(), EntryTime: nowMs,
Leverage: leverage, Leverage: leverage,
Status: "OPEN", Status: "OPEN",
CreatedAt: nowMs,
UpdatedAt: nowMs,
} }
if err := at.store.Position().Create(pos); err != nil { if err := at.store.Position().Create(pos); err != nil {
logger.Infof(" ⚠️ Failed to record position: %v", err) logger.Infof(" ⚠️ Failed to record position: %v", err)
@@ -1996,7 +2109,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
at.id, at.exchangeID, at.exchange, at.id, at.exchangeID, at.exchange,
symbol, side, action, symbol, side, action,
quantity, price, fee, 0, // realizedPnL will be calculated quantity, price, fee, 0, // realizedPnL will be calculated
time.Now(), orderID, time.Now().UTC().UnixMilli(), orderID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to process close position: %v", err) logger.Infof(" ⚠️ Failed to process close position: %v", err)
} else { } else {
@@ -2049,8 +2162,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
ReduceOnly: reduceOnly, ReduceOnly: reduceOnly,
ClosePosition: reduceOnly, ClosePosition: reduceOnly,
OrderAction: orderAction, OrderAction: orderAction,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now(), UpdatedAt: time.Now().UTC().UnixMilli(),
} }
} }
@@ -2076,22 +2189,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
normalizedSymbol := market.Normalize(symbol) normalizedSymbol := market.Normalize(symbol)
fill := &store.TraderFill{ fill := &store.TraderFill{
TraderID: at.id, TraderID: at.id,
ExchangeID: at.exchangeID, ExchangeID: at.exchangeID,
ExchangeType: at.exchange, ExchangeType: at.exchange,
OrderID: orderRecordID, OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID, ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID, ExchangeTradeID: tradeID,
Symbol: normalizedSymbol, Symbol: normalizedSymbol,
Side: side, Side: side,
Price: price, Price: price,
Quantity: quantity, Quantity: quantity,
QuoteQuantity: price * quantity, QuoteQuantity: price * quantity,
Commission: fee, Commission: fee,
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
// Calculate realized PnL for close orders // Calculate realized PnL for close orders
@@ -2215,3 +2328,7 @@ func getSideFromAction(action string) string {
} }
} }
// GetOpenOrders returns open orders (pending SL/TP) from exchange
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return at.trader.GetOpenOrders(symbol)
}

1850
trader/auto_trader_grid.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
package trader package binance
import ( import (
"context" "context"
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"nofx/hook" "nofx/hook"
"nofx/logger" "nofx/logger"
"nofx/trader/types"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -716,6 +717,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
return nil return nil
} }
// PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader
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 {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price to correct precision
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
if err != nil {
return nil, fmt.Errorf("failed to format price: %w", err)
}
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("Failed to set leverage: %v", err)
}
}
// Determine side and position side
var side futures.SideType
var positionSide futures.PositionSideType
if req.Side == "BUY" {
side = futures.SideTypeBuy
positionSide = futures.PositionSideTypeLong
} else {
side = futures.SideTypeSell
positionSide = futures.PositionSideTypeShort
}
// Build order service with broker ID
orderService := t.client.NewCreateOrderService().
Symbol(req.Symbol).
Side(side).
PositionSide(positionSide).
Type(futures.OrderTypeLimit).
TimeInForce(futures.TimeInForceTypeGTC).
Quantity(quantityStr).
Price(priceStr).
NewClientOrderID(getBrOrderID())
// Execute order
order, err := orderService.Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
return &types.LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID,
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Price: req.Price,
Quantity: req.Quantity,
Status: string(order.Status),
}, nil
}
// CancelOrder cancels a specific order by ID
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
// Parse order ID to int64
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(orderIDInt).
Do(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
book, err := t.client.NewDepthService().
Symbol(symbol).
Limit(depth).
Do(context.Background())
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
// Convert bids
bids = make([][]float64, len(book.Bids))
for i, bid := range book.Bids {
price, _ := strconv.ParseFloat(bid.Price, 64)
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
bids[i] = []float64{price, qty}
}
// Convert asks
asks = make([][]float64, len(book.Asks))
for i, ask := range book.Asks {
price, _ := strconv.ParseFloat(ask.Price, 64)
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
asks[i] = []float64{price, qty}
}
return bids, asks, nil
}
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions) // CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system) // Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
func (t *FuturesTrader) CancelStopOrders(symbol string) error { func (t *FuturesTrader) CancelStopOrders(symbol string) error {
@@ -776,6 +896,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
return nil return nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Type: string(order.Type),
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: string(order.Status),
})
}
// 2. Get Algo orders (new API for stop-loss/take-profit)
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
PositionSide: string(algoOrder.PositionSide),
Type: string(algoOrder.OrderType),
Price: 0, // Algo orders use stop price
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// GetMarketPrice gets market price // GetMarketPrice gets market price
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) { func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background()) prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
@@ -977,6 +1155,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
return fmt.Sprintf(format, quantity), nil return fmt.Sprintf(format, quantity), nil
} }
// GetSymbolPricePrecision gets the price precision for a trading pair
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
if err != nil {
return 0, fmt.Errorf("failed to get trading rules: %w", err)
}
for _, s := range exchangeInfo.Symbols {
if s.Symbol == symbol {
// Get precision from PRICE_FILTER filter
for _, filter := range s.Filters {
if filter["filterType"] == "PRICE_FILTER" {
tickSize := filter["tickSize"].(string)
precision := calculatePrecision(tickSize)
return precision, nil
}
}
}
}
// Default to 2 decimal places for price
return 2, nil
}
// FormatPrice formats price to correct precision
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
precision, err := t.GetSymbolPricePrecision(symbol)
if err != nil {
// If retrieval fails, use default format
return fmt.Sprintf("%.2f", price), nil
}
format := fmt.Sprintf("%%.%df", precision)
return fmt.Sprintf(format, price), nil
}
// Helper functions // Helper functions
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && stringContains(s, substr) return len(s) >= len(substr) && stringContains(s, substr)
@@ -1034,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. // 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. // 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. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord // Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
var records []ClosedPnLRecord var records []types.ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue // Skip opening trades continue // Skip opening trades
@@ -1070,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, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -1091,7 +1305,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
// GetTrades retrieves trade history from Binance Futures using Income API // GetTrades retrieves trade history from Binance Futures using Income API
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1109,7 +1323,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
return nil, fmt.Errorf("failed to get income history: %w", err) return nil, fmt.Errorf("failed to get income history: %w", err)
} }
var trades []TradeRecord var trades []types.TradeRecord
for _, income := range incomes { for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64) pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 { if pnl == 0 {
@@ -1118,11 +1332,11 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// Income API doesn't provide full trade details, create a minimal record // Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction // This is mainly used for detecting recent closures, not historical reconstruction
trade := TradeRecord{ trade := types.TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10), TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol, Symbol: income.Symbol,
RealizedPnL: pnl, RealizedPnL: pnl,
Time: time.UnixMilli(income.Time), Time: time.UnixMilli(income.Time).UTC(),
// Note: Income API doesn't provide price, quantity, side, fee // Note: Income API doesn't provide price, quantity, side, fee
// For accurate data, use GetTradesForSymbol with specific symbol // For accurate data, use GetTradesForSymbol with specific symbol
} }
@@ -1134,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// GetTradesForSymbol retrieves trade history for a specific symbol // GetTradesForSymbol retrieves trade history for a specific symbol
// This is more reliable than using Income API which may have delays // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1151,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) 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 { for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{ trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: string(at.Side), Side: string(at.Side),
@@ -1167,7 +1381,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -1177,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 // 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 // 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 { if limit <= 0 {
limit = 100 limit = 100
} }
@@ -1194,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) 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 { for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64) price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64) qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64) fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{ trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10), TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol, Symbol: at.Symbol,
Side: string(at.Side), Side: string(at.Side),
@@ -1210,7 +1424,7 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -1244,3 +1458,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
return symbols, nil return symbols, nil
} }
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(lastSyncTime.UnixMilli()).
Limit(1000).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get PnL history: %w", err)
}
symbolMap := make(map[string]bool)
for _, income := range incomes {
if income.Symbol != "" {
symbolMap[income.Symbol] = true
}
}
var symbols []string
for symbol := range symbolMap {
symbols = append(symbols, symbol)
}
return symbols, nil
}

View File

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

View File

@@ -1,19 +1,20 @@
package trader package binance
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market" "nofx/market"
"nofx/store" "nofx/store"
"nofx/trader/types"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
// syncState stores the last sync time for incremental sync // syncState stores the last sync time (Unix ms) for incremental sync
var ( var (
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
binanceSyncStateMutex sync.RWMutex binanceSyncStateMutex sync.RWMutex
) )
@@ -25,54 +26,112 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
return fmt.Errorf("store is nil") return fmt.Errorf("store is nil")
} }
// Get last sync time (default to 24 hours ago for first sync) orderStore := st.Order()
// Get last sync time (Unix ms) - first try memory, then database, then default
binanceSyncStateMutex.RLock() binanceSyncStateMutex.RLock()
lastSyncTime, exists := binanceSyncState[exchangeID] lastSyncTimeMs, exists := binanceSyncState[exchangeID]
binanceSyncStateMutex.RUnlock() binanceSyncStateMutex.RUnlock()
nowMs := time.Now().UTC().UnixMilli()
if !exists { if !exists {
lastSyncTime = time.Now().Add(-24 * time.Hour) // Try to get last fill time from database (persist across restarts)
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
if err == nil && lastFillTimeMs > 0 {
// If recovered time is in the future, it's clearly wrong - use default
if lastFillTimeMs > nowMs {
logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default",
lastFillTimeMs, nowMs)
lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago
} else {
// Add 1 second buffer to avoid re-fetching the same fill
lastSyncTimeMs = lastFillTimeMs + 1000
logger.Infof("📅 Recovered last sync time from DB: %s (UTC)",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
}
} else {
// First sync: go back 24 hours
lastSyncTimeMs = nowMs - 24*60*60*1000
logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
}
} }
// Record current time BEFORE querying, to avoid missing trades during sync logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
// This prevents race condition where trades happen between query and lastSyncTime update time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
syncStartTime := time.Now()
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
// Step 1: Get max trade IDs from local DB for incremental sync // Step 1: Get max trade IDs from local DB for incremental sync
orderStore := st.Order()
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID) maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
if err != nil { if err != nil {
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err) logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
maxTradeIDs = make(map[string]int64) maxTradeIDs = make(map[string]int64)
} }
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call) // Step 2: Detect symbols to sync using multiple methods
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime) // COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades)
symbolMap := make(map[string]bool)
lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls
// Method 1: COMMISSION income detection
commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime)
if err != nil { if err != nil {
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err) logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
// Fallback: only sync symbols with active positions } else {
changedSymbols = t.getPositionSymbols() logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols)
for _, s := range commissionSymbols {
symbolMap[s] = true
}
}
// Method 2: Always include active positions (catches trades that COMMISSION missed)
positionSymbols := t.getPositionSymbols()
logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols)
for _, s := range positionSymbols {
symbolMap[s] = true
}
// Method 3: Include symbols from recent fills in DB (in case some were partially synced)
recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs)
logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols)
for _, s := range recentSymbols {
symbolMap[s] = true
}
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
// IMPORTANT: Must run always, not just when symbolMap is empty,
// because a position might be fully closed (no active position) but have PnL
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
if err != nil {
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
} else {
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
}
}
var changedSymbols []string
for s := range symbolMap {
changedSymbols = append(changedSymbols, s)
} }
if len(changedSymbols) == 0 { if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync") logger.Infof("📭 No symbols with new trades to sync")
// Update last sync time even if no changes // DON'T update lastSyncTime to current time here!
binanceSyncStateMutex.Lock() // Keep using the last actual trade time from DB to avoid creating gaps
binanceSyncState[exchangeID] = syncStartTime // The lastSyncTimeMs from DB already has +1000ms buffer added
binanceSyncStateMutex.Unlock()
return nil return nil
} }
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols) 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) // 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 var failedSymbols []string
apiCalls := 0 apiCalls := 0
for _, symbol := range changedSymbols { for _, symbol := range changedSymbols {
var trades []TradeRecord var trades []types.TradeRecord
var queryErr error var queryErr error
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 { if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
@@ -94,23 +153,18 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls) logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
// Only update last sync time if ALL symbols were successfully queried
// This prevents data loss when some symbols fail due to rate limit or network issues
if len(failedSymbols) == 0 {
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTime
binanceSyncStateMutex.Unlock()
} else {
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
}
if len(allTrades) == 0 { if len(allTrades) == 0 {
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
// Don't update lastSyncTime, keep using DB value
if len(failedSymbols) > 0 {
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
}
return nil return nil
} }
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(allTrades, func(i, j int) bool { sort.Slice(allTrades, func(i, j int) bool {
return allTrades[i].Time.Before(allTrades[j].Time) return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli()
}) })
// Process trades one by one // Process trades one by one
@@ -118,10 +172,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
posBuilder := store.NewPositionBuilder(positionStore) posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0 syncedCount := 0
skippedCount := 0
for _, trade := range allTrades { for _, trade := range allTrades {
// Check if trade already exists // Check if trade already exists
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID) existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil { if err == nil && existing != nil {
skippedCount++
continue // Trade already exists, skip continue // Trade already exists, skip
} }
@@ -145,7 +201,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
// Normalize side // Normalize side
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -162,9 +219,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -173,7 +230,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -190,7 +247,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, IsMaker: false,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -202,7 +259,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {
@@ -210,11 +267,26 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
} }
syncedCount++ syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)",
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction) trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction,
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 return nil
} }
@@ -278,6 +350,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
// StartOrderSync starts background order sync task for Binance // StartOrderSync starts background order sync task for Binance
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
// Run first sync immediately
go func() {
logger.Infof("🔄 Running initial Binance order sync...")
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
}
}()
// Then run periodically
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
go func() { go func() {
for range ticker.C { for range ticker.C {

View File

@@ -0,0 +1,461 @@
package binance
import (
"context"
"fmt"
"os"
"testing"
"time"
)
func skipIfNoLiveTest(t *testing.T) {
if os.Getenv("BINANCE_LIVE_TEST") != "1" {
t.Skip("Skipping live test. Set BINANCE_LIVE_TEST=1 to run")
}
}
func getBinanceTestCredentials(t *testing.T) (string, string) {
apiKey := os.Getenv("BINANCE_TEST_API_KEY")
secretKey := os.Getenv("BINANCE_TEST_SECRET_KEY")
if apiKey == "" || secretKey == "" {
t.Skip("Skipping test. Set BINANCE_TEST_API_KEY and BINANCE_TEST_SECRET_KEY env vars")
}
return apiKey, secretKey
}
func createBinanceTestTrader(t *testing.T) *FuturesTrader {
apiKey, secretKey := getBinanceTestCredentials(t)
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
return trader
}
// TestBinanceConnection tests basic API connectivity
func TestBinanceConnection(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("Failed to get balance: %v", err)
}
t.Logf("✅ Connection OK - Balance: %v", balance)
}
// TestBinanceGetPositions tests position retrieval
func TestBinanceGetPositions(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
t.Logf("📊 Found %d positions with non-zero amount:", len(positions))
for i, pos := range positions {
symbol := pos["symbol"].(string)
side := pos["side"].(string)
posAmt := pos["positionAmt"].(float64)
entryPrice := pos["entryPrice"].(float64)
unrealizedPnl := pos["unRealizedProfit"].(float64)
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f pnl=%.4f",
i+1, symbol, side, posAmt, entryPrice, unrealizedPnl)
}
}
// TestBinanceGetCommissionSymbols tests COMMISSION income detection
func TestBinanceGetCommissionSymbols(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
// Test different time ranges
timeRanges := []struct {
name string
duration time.Duration
}{
{"1 hour", 1 * time.Hour},
{"24 hours", 24 * time.Hour},
{"7 days", 7 * 24 * time.Hour},
{"30 days", 30 * 24 * time.Hour},
}
for _, tr := range timeRanges {
startTime := time.Now().Add(-tr.duration)
symbols, err := trader.GetCommissionSymbols(startTime)
if err != nil {
t.Logf("❌ %s: Failed to get commission symbols: %v", tr.name, err)
continue
}
t.Logf("📋 %s: COMMISSION symbols = %d - %v", tr.name, len(symbols), symbols)
}
}
// TestBinanceGetPnLSymbols tests REALIZED_PNL income detection
func TestBinanceGetPnLSymbols(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
timeRanges := []struct {
name string
duration time.Duration
}{
{"1 hour", 1 * time.Hour},
{"24 hours", 24 * time.Hour},
{"7 days", 7 * 24 * time.Hour},
{"30 days", 30 * 24 * time.Hour},
}
for _, tr := range timeRanges {
startTime := time.Now().Add(-tr.duration)
symbols, err := trader.GetPnLSymbols(startTime)
if err != nil {
t.Logf("❌ %s: Failed to get PnL symbols: %v", tr.name, err)
continue
}
t.Logf("📋 %s: REALIZED_PNL symbols = %d - %v", tr.name, len(symbols), symbols)
}
}
// TestBinanceGetAllIncomeTypes tests all income types to understand data availability
func TestBinanceGetAllIncomeTypes(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
// All possible income types from Binance API
incomeTypes := []string{
"TRANSFER",
"WELCOME_BONUS",
"REALIZED_PNL",
"FUNDING_FEE",
"COMMISSION",
"INSURANCE_CLEAR",
"REFERRAL_KICKBACK",
"COMMISSION_REBATE",
"API_REBATE",
"CONTEST_REWARD",
"CROSS_COLLATERAL_TRANSFER",
"OPTIONS_PREMIUM_FEE",
"OPTIONS_SETTLE_PROFIT",
"INTERNAL_TRANSFER",
"AUTO_EXCHANGE",
"DELIVERED_SETTELMENT",
"COIN_SWAP_DEPOSIT",
"COIN_SWAP_WITHDRAW",
"POSITION_LIMIT_INCREASE_FEE",
}
startTime := time.Now().Add(-7 * 24 * time.Hour)
t.Logf("🔍 Checking all income types from %s:", startTime.Format(time.RFC3339))
for _, incomeType := range incomeTypes {
incomes, err := trader.client.NewGetIncomeHistoryService().
IncomeType(incomeType).
StartTime(startTime.UnixMilli()).
Limit(100).
Do(context.Background())
if err != nil {
t.Logf(" ❌ %s: error - %v", incomeType, err)
continue
}
if len(incomes) > 0 {
symbolMap := make(map[string]int)
for _, inc := range incomes {
if inc.Symbol != "" {
symbolMap[inc.Symbol]++
}
}
t.Logf(" ✅ %s: %d records, symbols: %v", incomeType, len(incomes), symbolMap)
} else {
t.Logf(" ⚪ %s: 0 records", incomeType)
}
}
}
// TestBinanceGetTradesForSymbol tests trade retrieval for specific symbols
func TestBinanceGetTradesForSymbol(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
// Common trading pairs
symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"}
startTime := time.Now().Add(-7 * 24 * time.Hour)
t.Logf("🔍 Checking trades for common symbols from %s:", startTime.Format(time.RFC3339))
for _, symbol := range symbols {
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
if err != nil {
t.Logf(" ❌ %s: error - %v", symbol, err)
continue
}
if len(trades) > 0 {
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
// Print first and last trade
first := trades[0]
last := trades[len(trades)-1]
t.Logf(" First: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
first.TradeID, first.Symbol, first.Side,
first.Quantity, first.Price, first.RealizedPnL,
first.Time.Format(time.RFC3339))
if len(trades) > 1 {
t.Logf(" Last: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
last.TradeID, last.Symbol, last.Side,
last.Quantity, last.Price, last.RealizedPnL,
last.Time.Format(time.RFC3339))
}
} else {
t.Logf(" ⚪ %s: 0 trades", symbol)
}
}
}
// TestBinanceTimestampFormats tests different timestamp formats
func TestBinanceTimestampFormats(t *testing.T) {
skipIfNoLiveTest(t)
now := time.Now()
nowUTC := time.Now().UTC()
t.Logf("🕐 Time comparison:")
t.Logf(" time.Now(): %s (UnixMilli: %d)", now.Format(time.RFC3339), now.UnixMilli())
t.Logf(" time.Now().UTC(): %s (UnixMilli: %d)", nowUTC.Format(time.RFC3339), nowUTC.UnixMilli())
t.Logf(" Difference: %v", now.Sub(nowUTC))
// The key insight: UnixMilli() should be the SAME regardless of timezone
if now.UnixMilli() != nowUTC.UnixMilli() {
t.Errorf("❌ UnixMilli() differs between local and UTC! This should never happen.")
} else {
t.Logf(" ✅ UnixMilli() is the same (correct behavior)")
}
// Test what happens when we parse a time stored in DB
// Simulate old DB value stored in local time
oldLocalTime := time.Date(2026, 1, 6, 18, 0, 0, 0, time.Local) // 18:00 local
oldLocalTimeAsUTC := time.Date(2026, 1, 6, 18, 0, 0, 0, time.UTC) // Same numbers but UTC
t.Logf("\n🔍 Timezone mismatch scenario:")
t.Logf(" Old DB time (local): %s (UnixMilli: %d)", oldLocalTime.Format(time.RFC3339), oldLocalTime.UnixMilli())
t.Logf(" Same time parsed as UTC: %s (UnixMilli: %d)", oldLocalTimeAsUTC.Format(time.RFC3339), oldLocalTimeAsUTC.UnixMilli())
t.Logf(" Difference: %v", time.Duration(oldLocalTimeAsUTC.UnixMilli()-oldLocalTime.UnixMilli())*time.Millisecond)
// If server is in +8 timezone, the difference should be 8 hours
_, offset := now.Zone()
t.Logf(" Local timezone offset: %d seconds (%d hours)", offset, offset/3600)
}
// TestBinanceFullSyncSimulation simulates the full sync process
func TestBinanceFullSyncSimulation(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
t.Logf("🔄 Simulating full sync process...")
// Step 1: Determine lastSyncTime (simulating first run)
lastSyncTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
t.Logf("\n📅 Step 1: lastSyncTime = %s", lastSyncTime.Format(time.RFC3339))
// Step 2: Detect symbols using all methods
symbolMap := make(map[string]bool)
// Method 1: COMMISSION
commissionSymbols, err := trader.GetCommissionSymbols(lastSyncTime)
if err != nil {
t.Logf(" ⚠️ COMMISSION failed: %v", err)
} else {
t.Logf(" 📋 COMMISSION symbols: %d - %v", len(commissionSymbols), commissionSymbols)
for _, s := range commissionSymbols {
symbolMap[s] = true
}
}
// Method 2: Positions
positions, err := trader.GetPositions()
if err != nil {
t.Logf(" ⚠️ GetPositions failed: %v", err)
} else {
var posSymbols []string
for _, pos := range positions {
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
posSymbols = append(posSymbols, symbol)
symbolMap[symbol] = true
}
}
t.Logf(" 📋 Position symbols: %d - %v", len(posSymbols), posSymbols)
}
// Method 3: REALIZED_PNL (fallback)
pnlSymbols, err := trader.GetPnLSymbols(lastSyncTime)
if err != nil {
t.Logf(" ⚠️ REALIZED_PNL failed: %v", err)
} else {
t.Logf(" 📋 REALIZED_PNL symbols: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
}
}
// Collect all symbols
var allSymbols []string
for s := range symbolMap {
allSymbols = append(allSymbols, s)
}
t.Logf("\n📊 Step 2: Total unique symbols to sync: %d - %v", len(allSymbols), allSymbols)
if len(allSymbols) == 0 {
t.Logf("❌ No symbols found! This is the bug - nothing to sync")
t.Logf("\n🔍 Investigating why no symbols found...")
// Try to query all income (without type filter) to see if there's ANY activity
incomes, err := trader.client.NewGetIncomeHistoryService().
StartTime(lastSyncTime.UnixMilli()).
Limit(100).
Do(context.Background())
if err != nil {
t.Logf(" Failed to get all income: %v", err)
} else {
t.Logf(" All income records (no type filter): %d", len(incomes))
typeCount := make(map[string]int)
for _, inc := range incomes {
typeCount[inc.IncomeType]++
}
t.Logf(" Income types breakdown: %v", typeCount)
}
return
}
// Step 3: Query trades for each symbol
t.Logf("\n📥 Step 3: Querying trades for each symbol...")
totalTrades := 0
for _, symbol := range allSymbols {
trades, err := trader.GetTradesForSymbol(symbol, lastSyncTime, 500)
if err != nil {
t.Logf(" ❌ %s: error - %v", symbol, err)
continue
}
totalTrades += len(trades)
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
// Print sample trades
for i, trade := range trades {
if i >= 3 {
t.Logf(" ... and %d more trades", len(trades)-3)
break
}
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f fee=%.6f time=%s",
i+1, trade.TradeID, trade.Symbol, trade.Side,
trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee,
trade.Time.Format(time.RFC3339))
}
}
t.Logf("\n✅ Sync simulation complete: %d total trades found across %d symbols",
totalTrades, len(allSymbols))
}
// TestBinanceTradeIDRange tests trade ID ranges to understand the data
func TestBinanceTradeIDRange(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
// First find symbols with trades
startTime := time.Now().Add(-30 * 24 * time.Hour)
commissionSymbols, _ := trader.GetCommissionSymbols(startTime)
pnlSymbols, _ := trader.GetPnLSymbols(startTime)
symbolMap := make(map[string]bool)
for _, s := range commissionSymbols {
symbolMap[s] = true
}
for _, s := range pnlSymbols {
symbolMap[s] = true
}
if len(symbolMap) == 0 {
t.Log("No symbols with activity found")
return
}
t.Logf("🔍 Checking trade ID ranges for symbols with activity:")
for symbol := range symbolMap {
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
if err != nil || len(trades) == 0 {
continue
}
var minID, maxID int64 = 1<<62, 0
for _, trade := range trades {
var id int64
fmt.Sscanf(trade.TradeID, "%d", &id)
if id < minID {
minID = id
}
if id > maxID {
maxID = id
}
}
t.Logf(" %s: %d trades, ID range [%d - %d]", symbol, len(trades), minID, maxID)
// Check if any ID exceeds PostgreSQL INTEGER max
if maxID > 2147483647 {
t.Logf(" ⚠️ Max trade ID %d exceeds PostgreSQL INTEGER max (2147483647)", maxID)
}
}
}
// TestBinanceIncomeAPIDirectCall makes direct API call to understand response
func TestBinanceIncomeAPIDirectCall(t *testing.T) {
skipIfNoLiveTest(t)
trader := createBinanceTestTrader(t)
startTime := time.Now().Add(-24 * time.Hour)
t.Logf("🔍 Direct income API call from %s:", startTime.Format(time.RFC3339))
t.Logf(" StartTime UnixMilli: %d", startTime.UnixMilli())
// Call without income type filter to get ALL income
incomes, err := trader.client.NewGetIncomeHistoryService().
StartTime(startTime.UnixMilli()).
Limit(1000).
Do(context.Background())
if err != nil {
t.Fatalf("Failed to get income: %v", err)
}
t.Logf("📋 Total income records: %d", len(incomes))
// Group by type and symbol
typeSymbolCount := make(map[string]map[string]int)
for _, inc := range incomes {
if typeSymbolCount[inc.IncomeType] == nil {
typeSymbolCount[inc.IncomeType] = make(map[string]int)
}
typeSymbolCount[inc.IncomeType][inc.Symbol]++
}
for incType, symbols := range typeSymbolCount {
t.Logf(" %s:", incType)
for symbol, count := range symbols {
if symbol == "" {
symbol = "(no symbol)"
}
t.Logf(" %s: %d records", symbol, count)
}
}
// Print sample records
if len(incomes) > 0 {
t.Logf("\n📝 Sample income records (first 5):")
for i, inc := range incomes {
if i >= 5 {
break
}
t.Logf(" [%d] Type=%s Symbol=%s Amount=%s Time=%s",
i+1, inc.IncomeType, inc.Symbol, inc.Income,
time.UnixMilli(inc.Time).Format(time.RFC3339))
}
}
}

View File

@@ -0,0 +1,218 @@
package binance
import (
"nofx/store"
"os"
"testing"
"time"
)
// TestBinanceSyncE2E tests the complete sync flow end-to-end
func TestBinanceSyncE2E(t *testing.T) {
skipIfNoLiveTest(t)
// Get credentials from environment
apiKey, secretKey := getBinanceTestCredentials(t)
// Create test database using full store initialization (includes table creation)
testDBPath := "/tmp/test_binance_sync.db"
os.Remove(testDBPath) // Clean up previous test
st, err := store.New(testDBPath)
if err != nil {
t.Fatalf("Failed to init test store: %v", err)
}
db := st.GormDB()
// Create trader
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
// Test parameters
traderID := "test-trader-id"
exchangeID := "test-exchange-id"
exchangeType := "binance"
t.Logf("🧪 Running end-to-end sync test...")
t.Logf(" DB Path: %s", testDBPath)
// Run sync
t.Logf("\n📥 Running SyncOrdersFromBinance...")
startTime := time.Now()
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
elapsed := time.Since(startTime)
if err != nil {
t.Fatalf("❌ Sync failed: %v", err)
}
t.Logf("✅ Sync completed in %v", elapsed)
// Check results in database
orderStore := st.Order()
// Count orders
var orderCount int64
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&orderCount)
t.Logf("\n📊 Results:")
t.Logf(" Orders in DB: %d", orderCount)
// Count fills
var fillCount int64
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
t.Logf(" Fills in DB: %d", fillCount)
// Get symbols
var symbols []string
db.Model(&store.TraderFill{}).
Select("DISTINCT symbol").
Where("exchange_id = ?", exchangeID).
Pluck("symbol", &symbols)
t.Logf(" Unique symbols: %d - %v", len(symbols), symbols)
// Check max trade IDs (test the fix)
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
if err != nil {
t.Logf(" ⚠️ GetMaxTradeIDsByExchange error: %v", err)
} else {
t.Logf(" Max trade IDs per symbol:")
for symbol, maxID := range maxTradeIDs {
if maxID > 2147483647 {
t.Logf(" %s: %d (⚠️ exceeds PostgreSQL INTEGER max)", symbol, maxID)
} else {
t.Logf(" %s: %d", symbol, maxID)
}
}
}
// Sample some orders
var sampleOrders []store.TraderOrder
db.Where("exchange_id = ?", exchangeID).Limit(5).Find(&sampleOrders)
if len(sampleOrders) > 0 {
t.Logf("\n📝 Sample orders:")
for i, order := range sampleOrders {
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
order.Quantity, order.Price, order.OrderAction,
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
}
}
// Test incremental sync - run again, should find no new trades
t.Logf("\n🔄 Running incremental sync (should skip existing trades)...")
startTime = time.Now()
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
elapsed = time.Since(startTime)
if err != nil {
t.Fatalf("❌ Incremental sync failed: %v", err)
}
t.Logf("✅ Incremental sync completed in %v", elapsed)
// Check counts again - should be the same
var newOrderCount int64
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&newOrderCount)
t.Logf(" Orders after incremental sync: %d (was %d)", newOrderCount, orderCount)
if newOrderCount != orderCount {
t.Logf(" ⚠️ Order count changed - possible duplicate detection issue")
} else {
t.Logf(" ✅ No duplicates - incremental sync working correctly")
}
// Test GetLastFillTimeByExchange
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
if err != nil {
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
} else {
lastFillTime := time.UnixMilli(lastFillTimeMs)
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
// Check if it would be in the future (the bug we fixed)
now := time.Now().UTC()
if lastFillTime.After(now) {
t.Logf(" ❌ BUG: Last fill time is in the future! (now: %s)", now.Format(time.RFC3339))
} else {
t.Logf(" ✅ Last fill time is in the past (correct)")
}
}
// Cleanup
os.Remove(testDBPath)
t.Logf("\n✅ E2E test completed successfully!")
}
// TestBinanceSyncWithExistingData tests sync behavior with pre-existing data
func TestBinanceSyncWithExistingData(t *testing.T) {
skipIfNoLiveTest(t)
// Get credentials from environment
apiKey, secretKey := getBinanceTestCredentials(t)
testDBPath := "/tmp/test_binance_sync_existing.db"
os.Remove(testDBPath)
st, err := store.New(testDBPath)
if err != nil {
t.Fatalf("Failed to init test store: %v", err)
}
db := st.GormDB()
orderStore := st.Order()
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
traderID := "test-trader-id"
exchangeID := "test-exchange-id"
exchangeType := "binance"
// Insert a fake "old" fill with LOCAL time (simulating the bug scenario)
// This tests that our timezone fix works
localTime := time.Now().Add(8 * time.Hour) // Simulate +8 timezone stored as if it were UTC
fakeFill := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: "fake-old-order",
ExchangeTradeID: "fake-old-trade",
Symbol: "BTCUSDT",
Side: "BUY",
Price: 50000,
Quantity: 0.001,
QuoteQuantity: 50,
CreatedAt: localTime.UnixMilli(), // This time is "in the future" if interpreted as UTC
}
if err := orderStore.CreateFill(fakeFill); err != nil {
t.Fatalf("Failed to create fake fill: %v", err)
}
t.Logf("🧪 Testing sync with existing 'future' data...")
t.Logf(" Fake fill time: %s", localTime.Format(time.RFC3339))
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
// Check GetLastFillTimeByExchange
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
if lastFillTime2.After(time.Now().UTC()) {
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
}
// Run sync - it should detect the future time and fall back
t.Logf("\n📥 Running sync (should detect future time and fall back)...")
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
if err != nil {
t.Fatalf("❌ Sync failed: %v", err)
}
t.Logf("✅ Sync completed")
// Check that trades were actually synced despite the bad data
var fillCount int64
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
t.Logf(" Total fills in DB: %d (includes 1 fake)", fillCount)
if fillCount > 1 {
t.Logf(" ✅ Real trades were synced despite 'future' data!")
} else {
t.Logf(" ❌ No real trades synced - the bug might still exist")
}
os.Remove(testDBPath)
}

View File

@@ -0,0 +1,511 @@
package binance
import (
"context"
"math"
"nofx/store"
"os"
"sort"
"strings"
"testing"
"time"
)
func repeatStr(s string, n int) string {
return strings.Repeat(s, n)
}
// TestBinanceSyncVerification verifies synced data matches exchange data exactly
func TestBinanceSyncVerification(t *testing.T) {
skipIfNoLiveTest(t)
// Get credentials from environment
apiKey, secretKey := getBinanceTestCredentials(t)
// Create test database
testDBPath := "/tmp/test_binance_verify.db"
os.Remove(testDBPath)
st, err := store.New(testDBPath)
if err != nil {
t.Fatalf("Failed to init test store: %v", err)
}
db := st.GormDB()
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
traderID := "test-trader-id"
exchangeID := "test-exchange-id"
exchangeType := "binance"
// Step 1: Run sync
t.Logf("%s", repeatStr("=", 60))
t.Logf("STEP 1: Running order sync...")
t.Logf("%s", repeatStr("=", 60))
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
if err != nil {
t.Fatalf("Sync failed: %v", err)
}
// Step 2: Get all trades from exchange for verification
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 2: Fetching trades from exchange for verification...")
t.Logf("%s", repeatStr("=", 60))
startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
// Get symbols from DB
var symbols []string
db.Model(&store.TraderFill{}).
Select("DISTINCT symbol").
Where("exchange_id = ?", exchangeID).
Pluck("symbol", &symbols)
t.Logf("Symbols to verify: %v", symbols)
// Fetch all trades from exchange
type ExchangeTrade struct {
TradeID string
Symbol string
Side string
Price float64
Quantity float64
Fee float64
RealizedPnL float64
Time time.Time
}
var exchangeTrades []ExchangeTrade
for _, symbol := range symbols {
trades, err := trader.GetTradesForSymbol(symbol, startTime, 1000)
if err != nil {
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
continue
}
for _, trade := range trades {
exchangeTrades = append(exchangeTrades, ExchangeTrade{
TradeID: trade.TradeID,
Symbol: trade.Symbol,
Side: trade.Side,
Price: trade.Price,
Quantity: trade.Quantity,
Fee: trade.Fee,
RealizedPnL: trade.RealizedPnL,
Time: trade.Time,
})
}
}
t.Logf("Total trades from exchange: %d", len(exchangeTrades))
// Step 3: Get all fills from DB
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 3: Comparing with local database...")
t.Logf("%s", repeatStr("=", 60))
var dbFills []store.TraderFill
db.Where("exchange_id = ?", exchangeID).Find(&dbFills)
t.Logf("Total fills in DB: %d", len(dbFills))
// Create maps for comparison
exchangeTradeMap := make(map[string]ExchangeTrade)
for _, t := range exchangeTrades {
exchangeTradeMap[t.TradeID] = t
}
dbFillMap := make(map[string]store.TraderFill)
for _, f := range dbFills {
dbFillMap[f.ExchangeTradeID] = f
}
// Step 4: Check for missing trades
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 4: Checking for MISSING trades (in exchange but not in DB)...")
t.Logf("%s", repeatStr("=", 60))
var missingTrades []ExchangeTrade
for tradeID, trade := range exchangeTradeMap {
if _, exists := dbFillMap[tradeID]; !exists {
missingTrades = append(missingTrades, trade)
}
}
if len(missingTrades) > 0 {
t.Logf("❌ MISSING %d trades:", len(missingTrades))
for i, trade := range missingTrades {
if i >= 10 {
t.Logf(" ... and %d more", len(missingTrades)-10)
break
}
t.Logf(" - %s %s %s qty=%.6f price=%.4f time=%s",
trade.TradeID, trade.Symbol, trade.Side,
trade.Quantity, trade.Price, trade.Time.Format(time.RFC3339))
}
} else {
t.Logf("✅ No missing trades")
}
// Step 5: Check for extra/duplicate trades
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 5: Checking for EXTRA trades (in DB but not in exchange)...")
t.Logf("%s", repeatStr("=", 60))
var extraTrades []store.TraderFill
for tradeID, fill := range dbFillMap {
if _, exists := exchangeTradeMap[tradeID]; !exists {
extraTrades = append(extraTrades, fill)
}
}
if len(extraTrades) > 0 {
t.Logf("❌ EXTRA %d trades in DB:", len(extraTrades))
for i, fill := range extraTrades {
if i >= 10 {
t.Logf(" ... and %d more", len(extraTrades)-10)
break
}
t.Logf(" - %s %s %s qty=%.6f price=%.4f",
fill.ExchangeTradeID, fill.Symbol, fill.Side,
fill.Quantity, fill.Price)
}
} else {
t.Logf("✅ No extra/duplicate trades")
}
// Step 6: Check for data accuracy
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 6: Verifying data accuracy (price, qty, fee, pnl)...")
t.Logf("%s", repeatStr("=", 60))
type DataMismatch struct {
TradeID string
Field string
DB float64
Exchange float64
}
var mismatches []DataMismatch
for tradeID, exchangeTrade := range exchangeTradeMap {
dbFill, exists := dbFillMap[tradeID]
if !exists {
continue
}
// Compare price
if !floatEqual(dbFill.Price, exchangeTrade.Price, 0.0001) {
mismatches = append(mismatches, DataMismatch{
TradeID: tradeID, Field: "Price",
DB: dbFill.Price, Exchange: exchangeTrade.Price,
})
}
// Compare quantity
if !floatEqual(dbFill.Quantity, exchangeTrade.Quantity, 0.000001) {
mismatches = append(mismatches, DataMismatch{
TradeID: tradeID, Field: "Quantity",
DB: dbFill.Quantity, Exchange: exchangeTrade.Quantity,
})
}
// Compare fee
if !floatEqual(dbFill.Commission, exchangeTrade.Fee, 0.000001) {
mismatches = append(mismatches, DataMismatch{
TradeID: tradeID, Field: "Fee",
DB: dbFill.Commission, Exchange: exchangeTrade.Fee,
})
}
// Compare realized PnL
if !floatEqual(dbFill.RealizedPnL, exchangeTrade.RealizedPnL, 0.01) {
mismatches = append(mismatches, DataMismatch{
TradeID: tradeID, Field: "RealizedPnL",
DB: dbFill.RealizedPnL, Exchange: exchangeTrade.RealizedPnL,
})
}
}
if len(mismatches) > 0 {
t.Logf("❌ DATA MISMATCHES: %d", len(mismatches))
for i, m := range mismatches {
if i >= 20 {
t.Logf(" ... and %d more", len(mismatches)-20)
break
}
t.Logf(" - %s %s: DB=%.6f, Exchange=%.6f",
m.TradeID, m.Field, m.DB, m.Exchange)
}
} else {
t.Logf("✅ All data matches exactly")
}
// Step 7: Summary by symbol
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 7: Summary by symbol...")
t.Logf("%s", repeatStr("=", 60))
type SymbolSummary struct {
Symbol string
ExchangeCount int
DBCount int
TotalQty float64
TotalFee float64
TotalPnL float64
ExchangeTotalQty float64
ExchangeTotalFee float64
ExchangeTotalPnL float64
}
summaryMap := make(map[string]*SymbolSummary)
for _, trade := range exchangeTrades {
if summaryMap[trade.Symbol] == nil {
summaryMap[trade.Symbol] = &SymbolSummary{Symbol: trade.Symbol}
}
s := summaryMap[trade.Symbol]
s.ExchangeCount++
s.ExchangeTotalQty += trade.Quantity
s.ExchangeTotalFee += trade.Fee
s.ExchangeTotalPnL += trade.RealizedPnL
}
for _, fill := range dbFills {
if summaryMap[fill.Symbol] == nil {
summaryMap[fill.Symbol] = &SymbolSummary{Symbol: fill.Symbol}
}
s := summaryMap[fill.Symbol]
s.DBCount++
s.TotalQty += fill.Quantity
s.TotalFee += fill.Commission
s.TotalPnL += fill.RealizedPnL
}
t.Logf("\n%-15s %10s %10s %15s %15s %15s", "Symbol", "Exchange", "DB", "Fee(Exc/DB)", "PnL(Exc/DB)", "Match")
t.Logf("%s", repeatStr("-", 80))
for _, s := range summaryMap {
countMatch := s.ExchangeCount == s.DBCount
feeMatch := floatEqual(s.ExchangeTotalFee, s.TotalFee, 0.01)
pnlMatch := floatEqual(s.ExchangeTotalPnL, s.TotalPnL, 0.01)
matchStr := "✅"
if !countMatch || !feeMatch || !pnlMatch {
matchStr = "❌"
}
t.Logf("%-15s %10d %10d %7.2f/%-7.2f %7.2f/%-7.2f %s",
s.Symbol, s.ExchangeCount, s.DBCount,
s.ExchangeTotalFee, s.TotalFee,
s.ExchangeTotalPnL, s.TotalPnL,
matchStr)
}
// Step 8: Position verification
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("STEP 8: Verifying position calculations...")
t.Logf("%s", repeatStr("=", 60))
// Get positions from DB
var dbPositions []store.TraderPosition
db.Where("exchange_id = ? AND status = ?", exchangeID, "closed").Find(&dbPositions)
t.Logf("Closed positions in DB: %d", len(dbPositions))
// Get current positions from exchange
exchangePositions, err := trader.GetPositions()
if err != nil {
t.Logf("⚠️ Failed to get exchange positions: %v", err)
} else {
t.Logf("Active positions on exchange: %d", len(exchangePositions))
for _, pos := range exchangePositions {
t.Logf(" - %s %s qty=%.6f entry=%.4f pnl=%.4f",
pos["symbol"], pos["side"],
pos["positionAmt"], pos["entryPrice"], pos["unRealizedProfit"])
}
}
// Calculate total PnL from trades
var totalRealizedPnL float64
var totalFees float64
for _, fill := range dbFills {
totalRealizedPnL += fill.RealizedPnL
totalFees += fill.Commission
}
t.Logf("\n📊 PnL Summary from DB:")
t.Logf(" Total Realized PnL: %.4f USDT", totalRealizedPnL)
t.Logf(" Total Fees: %.4f USDT", totalFees)
t.Logf(" Net PnL: %.4f USDT", totalRealizedPnL-totalFees)
// Calculate from exchange
var exchangeTotalPnL float64
var exchangeTotalFees float64
for _, trade := range exchangeTrades {
exchangeTotalPnL += trade.RealizedPnL
exchangeTotalFees += trade.Fee
}
t.Logf("\n📊 PnL Summary from Exchange:")
t.Logf(" Total Realized PnL: %.4f USDT", exchangeTotalPnL)
t.Logf(" Total Fees: %.4f USDT", exchangeTotalFees)
t.Logf(" Net PnL: %.4f USDT", exchangeTotalPnL-exchangeTotalFees)
// Compare
pnlMatch := floatEqual(totalRealizedPnL, exchangeTotalPnL, 0.01)
feeMatch := floatEqual(totalFees, exchangeTotalFees, 0.01)
t.Logf("\n%s", repeatStr("=", 60))
t.Logf("FINAL VERIFICATION RESULT")
t.Logf("%s", repeatStr("=", 60))
allPassed := true
if len(missingTrades) > 0 {
t.Logf("❌ Missing trades: %d", len(missingTrades))
allPassed = false
} else {
t.Logf("✅ No missing trades")
}
if len(extraTrades) > 0 {
t.Logf("❌ Extra/duplicate trades: %d", len(extraTrades))
allPassed = false
} else {
t.Logf("✅ No extra/duplicate trades")
}
if len(mismatches) > 0 {
t.Logf("❌ Data mismatches: %d", len(mismatches))
allPassed = false
} else {
t.Logf("✅ All data accurate")
}
if !pnlMatch {
t.Logf("❌ PnL mismatch: DB=%.4f, Exchange=%.4f", totalRealizedPnL, exchangeTotalPnL)
allPassed = false
} else {
t.Logf("✅ PnL matches")
}
if !feeMatch {
t.Logf("❌ Fee mismatch: DB=%.4f, Exchange=%.4f", totalFees, exchangeTotalFees)
allPassed = false
} else {
t.Logf("✅ Fees match")
}
if allPassed {
t.Logf("\n🎉 ALL VERIFICATIONS PASSED!")
} else {
t.Logf("\n⚠ SOME VERIFICATIONS FAILED - CHECK ABOVE FOR DETAILS")
}
// Cleanup
os.Remove(testDBPath)
}
// floatEqual compares two floats with tolerance
func floatEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) <= tolerance
}
// TestBinanceDetailedTradeComparison shows detailed trade-by-trade comparison
func TestBinanceDetailedTradeComparison(t *testing.T) {
skipIfNoLiveTest(t)
// Get credentials from environment
apiKey, secretKey := getBinanceTestCredentials(t)
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
startTime := time.Now().UTC().Add(-24 * time.Hour)
// Get all income (to find symbols with activity)
incomes, err := trader.client.NewGetIncomeHistoryService().
StartTime(startTime.UnixMilli()).
Limit(100).
Do(context.Background())
if err != nil {
t.Fatalf("Failed to get income: %v", err)
}
// Find unique symbols
symbolMap := make(map[string]bool)
for _, inc := range incomes {
if inc.Symbol != "" {
symbolMap[inc.Symbol] = true
}
}
if len(symbolMap) == 0 {
t.Log("No trading activity in the last 24 hours")
return
}
t.Logf("=%s", repeatStr("=", 100))
t.Logf("DETAILED TRADE REPORT (Last 24 hours)")
t.Logf("=%s", repeatStr("=", 100))
var grandTotalQty float64
var grandTotalFee float64
var grandTotalPnL float64
for symbol := range symbolMap {
trades, err := trader.GetTradesForSymbol(symbol, startTime, 500)
if err != nil {
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
continue
}
if len(trades) == 0 {
continue
}
// Sort by time
sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time)
})
t.Logf("\n%s", repeatStr("-", 100))
t.Logf("📊 %s - %d trades", symbol, len(trades))
t.Logf("%s", repeatStr("-", 100))
t.Logf("%-15s %-6s %12s %12s %12s %12s %20s",
"TradeID", "Side", "Quantity", "Price", "Fee", "PnL", "Time")
var totalQty, totalFee, totalPnL float64
var buyQty, sellQty float64
for _, trade := range trades {
t.Logf("%-15s %-6s %12.6f %12.4f %12.6f %12.4f %20s",
trade.TradeID, trade.Side,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time.Format("2006-01-02 15:04:05"))
totalQty += trade.Quantity
totalFee += trade.Fee
totalPnL += trade.RealizedPnL
if trade.Side == "BUY" {
buyQty += trade.Quantity
} else {
sellQty += trade.Quantity
}
}
t.Logf("%s", repeatStr("-", 100))
t.Logf("SUBTOTAL: %d trades, Buy=%.6f, Sell=%.6f, Fee=%.6f, PnL=%.4f",
len(trades), buyQty, sellQty, totalFee, totalPnL)
grandTotalQty += totalQty
grandTotalFee += totalFee
grandTotalPnL += totalPnL
}
t.Logf("\n%s", repeatStr("=", 100))
t.Logf("GRAND TOTAL")
t.Logf("=%s", repeatStr("=", 100))
t.Logf("Total Fee: %.6f USDT", grandTotalFee)
t.Logf("Total PnL: %.4f USDT", grandTotalPnL)
t.Logf("Net PnL: %.4f USDT", grandTotalPnL-grandTotalFee)
}

View File

@@ -1,4 +1,4 @@
package trader package bitget
import ( import (
"encoding/json" "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) return nil, fmt.Errorf("failed to get fill history: %w", err)
} }
var resp struct {
FillList []struct { // Bitget fill structure - supports both one-way and hedge mode
TradeID string `json:"tradeId"` type BitgetFill struct {
Symbol string `json:"symbol"` TradeID string `json:"tradeId"`
OrderID string `json:"orderId"` Symbol string `json:"symbol"`
Side string `json:"side"` // buy, sell OrderID string `json:"orderId"`
Price string `json:"price"` // Fill price Side string `json:"side"` // buy, sell
BaseVolume string `json:"baseVolume"` // Fill size in base currency Price string `json:"price"` // Fill price
Fee string `json:"fee"` // Fee (negative for cost) BaseVolume string `json:"baseVolume"` // Fill size in base currency
FeeCcy string `json:"feeCcy"` // Fee currency Profit string `json:"profit"` // Realized PnL
Profit string `json:"profit"` // Realized PnL CTime string `json:"cTime"` // Fill time (ms)
CTime string `json:"cTime"` // Fill time (ms) TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close
TradeSide string `json:"tradeSide"` // open, close FeeDetail []struct {
} `json:"fillList"` FeeCoin string `json:"feeCoin"`
TotalFee string `json:"totalFee"`
} `json:"feeDetail"`
} }
if err := json.Unmarshal(data, &resp); err != nil { // Try parsing as wrapped response first (fillList field)
return nil, fmt.Errorf("failed to parse fills: %w", err) 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) fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64) fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
fee, _ := strconv.ParseFloat(fill.Fee, 64)
profit, _ := strconv.ParseFloat(fill.Profit, 64) profit, _ := strconv.ParseFloat(fill.Profit, 64)
cTime, _ := strconv.ParseInt(fill.CTime, 10, 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 // Determine order action based on side and tradeSide
// Bitget one-way mode: // Bitget one-way mode: buy_single (open long), sell_single (close long)
// - buy + open = open long // Bitget hedge mode: open + buy = open_long, close + sell = close_long
// - sell + open = open short
// - sell + close = close long
// - buy + close = close short
orderAction := "open_long" orderAction := "open_long"
side := strings.ToLower(fill.Side) side := strings.ToLower(fill.Side)
tradeSide := strings.ToLower(fill.TradeSide) 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" { if side == "buy" {
orderAction = "open_long" orderAction = "open_long"
} else { } else {
orderAction = "open_short" orderAction = "open_short"
} }
} else if tradeSide == "close" { } else if tradeSide == "close" {
// Hedge mode: close
if side == "sell" { if side == "sell" {
orderAction = "close_long" orderAction = "close_long"
} else { } else {
@@ -108,9 +138,9 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
Side: fill.Side, Side: fill.Side,
FillPrice: fillPrice, FillPrice: fillPrice,
FillQty: fillQty, FillQty: fillQty,
Fee: -fee, // Bitget returns negative fee Fee: -fee, // Bitget returns negative fee, convert to positive
FeeAsset: fill.FeeCcy, FeeAsset: feeAsset,
ExecTime: time.UnixMilli(cTime), ExecTime: time.UnixMilli(cTime).UTC(),
ProfitLoss: profit, ProfitLoss: profit,
OrderType: "MARKET", OrderType: "MARKET",
OrderAction: orderAction, OrderAction: orderAction,
@@ -146,7 +176,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.Before(trades[j].ExecTime) return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -174,7 +204,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -191,9 +222,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
FilledQuantity: trade.FillQty, FilledQuantity: trade.FillQty,
AvgFillPrice: trade.FillPrice, AvgFillPrice: trade.FillPrice,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.ExecTime, FilledAt: execTimeMs,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
UpdatedAt: trade.ExecTime, UpdatedAt: execTimeMs,
} }
// Insert order record // Insert order record
@@ -202,7 +233,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
continue continue
} }
// Create fill record // Create fill record - use UTC time in milliseconds
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -219,7 +250,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
CommissionAsset: trade.FeeAsset, CommissionAsset: trade.FeeAsset,
RealizedPnL: trade.ProfitLoss, RealizedPnL: trade.ProfitLoss,
IsMaker: false, IsMaker: false,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -231,7 +262,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
trade.ExecTime, trade.TradeID, execTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1,4 +1,4 @@
package trader package bitget
import ( import (
"bytes" "bytes"
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"nofx/trader/types"
) )
// Bitget API endpoints (V2) // Bitget API endpoints (V2)
@@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
} }
// GetClosedPnL retrieves closed position PnL records // 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 { if limit <= 0 {
limit = 100 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) 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 { for _, pos := range resp.List {
record := ClosedPnLRecord{ record := types.ClosedPnLRecord{
Symbol: pos.Symbol, Symbol: pos.Symbol,
Side: pos.HoldSide, Side: pos.HoldSide,
} }
@@ -1069,8 +1070,8 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime) record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime) record.ExitTime = time.UnixMilli(uTime).UTC()
record.CloseType = "unknown" record.CloseType = "unknown"
records = append(records, record) records = append(records, record)
@@ -1096,3 +1097,264 @@ func genBitgetClientOid() string {
rand := time.Now().Nanosecond() % 100000 rand := time.Now().Nanosecond() % 100000
return fmt.Sprintf("nofx%d%05d", timestamp, rand) return fmt.Sprintf("nofx%d%05d", timestamp, rand)
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
symbol = t.convertSymbol(symbol)
var result []types.OpenOrder
// 1. Get pending limit orders
params := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
}
data, err := t.doRequest("GET", bitgetPendingPath, params)
if err != nil {
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
}
if err == nil && data != nil {
var orders struct {
EntrustedList []struct {
OrderId string `json:"orderId"`
Symbol string `json:"symbol"`
Side string `json:"side"` // buy/sell
TradeSide string `json:"tradeSide"` // open/close
PosSide string `json:"posSide"` // long/short
OrderType string `json:"orderType"` // limit/market
Price string `json:"price"`
Size string `json:"size"`
State string `json:"state"`
} `json:"entrustedList"`
}
if err := json.Unmarshal(data, &orders); err == nil {
for _, order := range orders.EntrustedList {
price, _ := strconv.ParseFloat(order.Price, 64)
quantity, _ := strconv.ParseFloat(order.Size, 64)
// Convert side to standard format
side := strings.ToUpper(order.Side)
positionSide := strings.ToUpper(order.PosSide)
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: strings.ToUpper(order.OrderType),
Price: price,
StopPrice: 0,
Quantity: quantity,
Status: "NEW",
})
}
}
}
// 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{}{
"productType": "USDT-FUTURES",
"planType": "profit_loss",
}
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
if err != nil {
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
}
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"` // 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 {
// 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)
result = append(result, types.OpenOrder{
OrderID: order.OrderId,
Symbol: order.Symbol,
Side: side,
PositionSide: positionSide,
Type: orderType,
Price: 0,
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
}
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
symbol := t.convertSymbol(req.Symbol)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
}
}
// Format quantity
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
// Determine side
side := "buy"
if req.Side == "SELL" {
side = "sell"
}
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"marginMode": "crossed",
"marginCoin": "USDT",
"side": side,
"orderType": "limit",
"size": qtyStr,
"price": fmt.Sprintf("%.8f", req.Price),
"force": "GTC", // Good Till Cancel
"clientOid": genBitgetClientOid(),
}
// Add reduce only if specified
if req.ReduceOnly {
body["reduceOnly"] = "YES"
}
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
data, err := t.doRequest("POST", bitgetOrderPath, body)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
var order struct {
OrderId string `json:"orderId"`
ClientOid string `json:"clientOid"`
}
if err := json.Unmarshal(data, &order); err != nil {
return nil, fmt.Errorf("failed to parse order response: %w", err)
}
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
symbol, side, req.Price, order.OrderId)
return &types.LimitOrderResult{
OrderID: order.OrderId,
ClientID: order.ClientOid,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: req.Price,
Quantity: req.Quantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
symbol = t.convertSymbol(symbol)
body := map[string]interface{}{
"symbol": symbol,
"productType": "USDT-FUTURES",
"orderId": orderID,
}
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
symbol = t.convertSymbol(symbol)
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
data, err := t.doRequest("GET", path, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
var result struct {
Bids [][]string `json:"bids"`
Asks [][]string `json:"asks"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
// Parse bids
for _, b := range result.Bids {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Asks {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

@@ -1,4 +1,4 @@
package trader package bybit
import ( import (
"crypto/hmac" "crypto/hmac"
@@ -127,7 +127,7 @@ func (t *BybitTrader) parseTradesResult(list []map[string]interface{}) ([]BybitT
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64) closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64) closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64) execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
execTime := time.UnixMilli(execTimeMs) execTime := time.UnixMilli(execTimeMs).UTC()
// Determine order action based on side and closedSize // Determine order action based on side and closedSize
// If closedSize > 0, it's a close trade // If closedSize > 0, it's a close trade
@@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.Before(trades[j].ExecTime) return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -223,7 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -240,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
FilledQuantity: trade.ExecQty, FilledQuantity: trade.ExecQty,
AvgFillPrice: trade.ExecPrice, AvgFillPrice: trade.ExecPrice,
Commission: trade.ExecFee, Commission: trade.ExecFee,
FilledAt: trade.ExecTime, FilledAt: execTimeMs,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
UpdatedAt: trade.ExecTime, UpdatedAt: execTimeMs,
} }
// Insert order record // Insert order record
@@ -251,7 +252,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
continue continue
} }
// Create fill record // Create fill record - use UTC time
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -268,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.ClosedPnL, RealizedPnL: trade.ClosedPnL,
IsMaker: trade.IsMaker, IsMaker: trade.IsMaker,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -280,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL, trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
trade.ExecTime, trade.ExecID, execTimeMs, trade.ExecID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
} else { } else {

View File

@@ -1,4 +1,4 @@
package trader package bybit
import ( import (
"context" "context"
@@ -17,6 +17,7 @@ import (
"time" "time"
bybit "github.com/bybit-exchange/bybit.go.api" bybit "github.com/bybit-exchange/bybit.go.api"
"nofx/trader/types"
) )
// BybitTrader Bybit USDT Perpetual Futures Trader // 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 // 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 // The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit) return t.getClosedPnLViaHTTP(startTime, limit)
} }
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing // 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 // Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit) queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams 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 // 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{}) data, ok := resultData.(map[string]interface{})
if !ok { if !ok {
return nil, fmt.Errorf("invalid result format") return nil, fmt.Errorf("invalid result format")
} }
list, _ := data["list"].([]interface{}) list, _ := data["list"].([]interface{})
var records []ClosedPnLRecord var records []types.ClosedPnLRecord
for _, item := range list { for _, item := range list {
pnl, ok := item.(map[string]interface{}) pnl, ok := item.(map[string]interface{})
@@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
normalizedSide = "short" normalizedSide = "short"
} }
record := ClosedPnLRecord{ record := types.ClosedPnLRecord{
Symbol: symbol, Symbol: symbol,
Side: normalizedSide, Side: normalizedSide,
EntryPrice: avgEntryPrice, EntryPrice: avgEntryPrice,
@@ -1032,8 +1033,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
RealizedPnL: closedPnL, RealizedPnL: closedPnL,
Fee: fee, Fee: fee,
Leverage: int(leverage), Leverage: int(leverage),
EntryTime: time.UnixMilli(createdTime), EntryTime: time.UnixMilli(createdTime).UTC(),
ExitTime: time.UnixMilli(updatedTime), ExitTime: time.UnixMilli(updatedTime).UTC(),
OrderID: orderId, OrderID: orderId,
CloseType: "unknown", // Bybit doesn't provide close type directly CloseType: "unknown", // Bybit doesn't provide close type directly
ExchangeID: orderId, // Use orderId as exchange ID ExchangeID: orderId, // Use orderId as exchange ID
@@ -1044,3 +1045,220 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
return records, nil return records, nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderFilter": "StopOrder",
}
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
if resp.RetCode == 0 {
resultData, ok := resp.Result.(map[string]interface{})
if ok {
list, _ := resultData["list"].([]interface{})
for _, item := range list {
order, ok := item.(map[string]interface{})
if !ok {
continue
}
orderId, _ := order["orderId"].(string)
sym, _ := order["symbol"].(string)
side, _ := order["side"].(string)
orderType, _ := order["orderType"].(string)
stopOrderType, _ := order["stopOrderType"].(string)
triggerPrice, _ := order["triggerPrice"].(string)
qty, _ := order["qty"].(string)
price, _ := strconv.ParseFloat(triggerPrice, 64)
quantity, _ := strconv.ParseFloat(qty, 64)
// Determine type based on stopOrderType
displayType := orderType
if stopOrderType != "" {
displayType = stopOrderType
}
result = append(result, types.OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
PositionSide: "", // Bybit doesn't use positionSide for UTA
Type: displayType,
Price: 0,
StopPrice: price,
Quantity: quantity,
Status: "NEW",
})
}
}
}
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
// Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to format quantity: %w", err)
}
// Format price
priceStr := fmt.Sprintf("%.8f", req.Price)
// Set leverage if specified
if req.Leverage > 0 {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
}
}
// Determine side
side := "Buy"
if req.Side == "SELL" {
side = "Sell"
}
params := map[string]interface{}{
"category": "linear",
"symbol": req.Symbol,
"side": side,
"orderType": "Limit",
"qty": qtyStr,
"price": priceStr,
"timeInForce": "GTC", // Good Till Cancel
"positionIdx": 0, // One-way position mode
}
// Add reduce only if specified
if req.ReduceOnly {
params["reduceOnly"] = true
}
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Parse result
orderID := ""
if result.RetCode == 0 {
if resultData, ok := result.Result.(map[string]interface{}); ok {
if id, ok := resultData["orderId"].(string); ok {
orderID = id
}
}
} else {
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID)
return &types.LimitOrderResult{
OrderID: orderID,
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 by ID
// Implements GridTrader interface
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderId": orderID,
}
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
if result.RetCode != 0 {
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
}
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
if depth <= 0 {
depth = 25
}
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
resp, err := http.Get(url)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
RetCode int `json:"retCode"`
RetMsg string `json:"retMsg"`
Result struct {
S string `json:"s"` // symbol
B [][]string `json:"b"` // bids [[price, size], ...]
A [][]string `json:"a"` // asks [[price, size], ...]
} `json:"result"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
}
if result.RetCode != 0 {
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
}
// Parse bids
for _, b := range result.Result.B {
if len(b) >= 2 {
price, _ := strconv.ParseFloat(b[0], 64)
qty, _ := strconv.ParseFloat(b[1], 64)
bids = append(bids, []float64{price, qty})
}
}
// Parse asks
for _, a := range result.Result.A {
if len(a) >= 2 {
price, _ := strconv.ParseFloat(a[0], 64)
qty, _ := strconv.ParseFloat(a[1], 64)
asks = append(asks, []float64{price, qty})
}
}
return bids, asks, nil
}

View File

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

View File

@@ -141,7 +141,7 @@ func runStandardTests(t *testing.T, exchangeName string) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
trade.Symbol, trade.Side, trade.Action, trade.Symbol, trade.Side, trade.Action,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
"", "",
) )
if err != nil { if err != nil {
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
"ETHUSDT", "LONG", "open_long", "ETHUSDT", "LONG", "open_long",
0.1, 3500+float64(i*10), 0.5, 0, 0.1, 3500+float64(i*10), 0.5, 0,
time.Now().Add(time.Duration(i*2)*time.Second), time.Now().Add(time.Duration(i*2)*time.Second).UnixMilli(),
"", "",
) )
if err != nil { if err != nil {
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
"ETHUSDT", "LONG", "close_long", "ETHUSDT", "LONG", "close_long",
0.1, 3600+float64(i*10), 0.5, 10, 0.1, 3600+float64(i*10), 0.5, 10,
time.Now().Add(time.Duration(i*2+1)*time.Second), time.Now().Add(time.Duration(i*2+1)*time.Second).UnixMilli(),
"", "",
) )
if err != nil { if err != nil {
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
"BTCUSDT", "LONG", "open_long", "BTCUSDT", "LONG", "open_long",
0.01, 50000, 1.0, 0, 0.01, 50000, 1.0, 0,
time.Now(), time.Now().UnixMilli(),
"", "",
) )
if err != nil { if err != nil {
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
"BTCUSDT", "LONG", "close_long", "BTCUSDT", "LONG", "close_long",
0.00999999, 51000, 1.0, 10, 0.00999999, 51000, 1.0, 10,
time.Now().Add(time.Second), time.Now().Add(time.Second).UnixMilli(),
"", "",
) )
if err != nil { if err != nil {

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

@@ -0,0 +1,304 @@
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, err := strconv.ParseFloat(trade.Price, 64)
if err != nil || fillPrice == 0 {
logger.Infof("⚠️ Gate trade %d: fillPrice parse issue - raw='%s' parsed=%.8f err=%v",
trade.Id, trade.Price, fillPrice, err)
}
// 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 {
// 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"
}
execTimeMs := trade.ExecTime.UTC().UnixMilli()
// 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 {
// Order exists, but still try to update position for close trades
// This handles the case where order was created but position update failed
if strings.HasPrefix(trade.OrderAction, "close_") && trade.FillPrice > 0 {
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(" ⚠️ Retry position update for existing trade %s failed: %v", trade.TradeID, err)
}
}
continue
}
// Normalize side for storage
side := strings.ToUpper(trade.Side)
// Create order record
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
// Debug: Log the price being passed to ensure it's not 0
if trade.FillPrice <= 0 {
logger.Infof(" ⚠️ WARNING: trade %s has FillPrice=%.10f (invalid), skipping position update", trade.TradeID, trade.FillPrice)
} else {
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, price: %.10f)", trade.TradeID, trade.OrderAction, trade.FillQty, trade.FillPrice)
}
}
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)
}

312
trader/grid_regime.go Normal file
View File

@@ -0,0 +1,312 @@
package trader
import (
"nofx/market"
"nofx/store"
"time"
)
// ============================================================================
// Task 6: Regime Level Classification
// ============================================================================
// classifyRegimeLevel determines the regime level based on market indicators
// bollingerWidth: Bollinger band width as percentage
// atr14Pct: ATR14 as percentage of current price
func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel {
// Narrow: Bollinger < 2%, ATR < 1%
if bollingerWidth < 2.0 && atr14Pct < 1.0 {
return market.RegimeLevelNarrow
}
// Standard: Bollinger 2-3%, ATR 1-2%
if bollingerWidth <= 3.0 && atr14Pct <= 2.0 {
return market.RegimeLevelStandard
}
// Wide: Bollinger 3-4%, ATR 2-3%
if bollingerWidth <= 4.0 && atr14Pct <= 3.0 {
return market.RegimeLevelWide
}
// Volatile: Bollinger > 4%, ATR > 3%
return market.RegimeLevelVolatile
}
// getRegimeLeverageLimit returns the effective leverage limit for a regime level
func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridConfigModel) int {
switch level {
case market.RegimeLevelNarrow:
if config.NarrowRegimeLeverage > 0 {
return config.NarrowRegimeLeverage
}
return 2
case market.RegimeLevelStandard:
if config.StandardRegimeLeverage > 0 {
return config.StandardRegimeLeverage
}
return 4
case market.RegimeLevelWide:
if config.WideRegimeLeverage > 0 {
return config.WideRegimeLeverage
}
return 3
case market.RegimeLevelVolatile:
if config.VolatileRegimeLeverage > 0 {
return config.VolatileRegimeLeverage
}
return 2
default:
return 2 // Conservative default
}
}
// getRegimePositionLimit returns the position limit percentage for a regime level
func getRegimePositionLimit(level market.RegimeLevel, config *store.GridConfigModel) float64 {
switch level {
case market.RegimeLevelNarrow:
if config.NarrowRegimePositionPct > 0 {
return config.NarrowRegimePositionPct
}
return 40.0
case market.RegimeLevelStandard:
if config.StandardRegimePositionPct > 0 {
return config.StandardRegimePositionPct
}
return 70.0
case market.RegimeLevelWide:
if config.WideRegimePositionPct > 0 {
return config.WideRegimePositionPct
}
return 60.0
case market.RegimeLevelVolatile:
if config.VolatileRegimePositionPct > 0 {
return config.VolatileRegimePositionPct
}
return 40.0
default:
return 40.0 // Conservative default
}
}
// ============================================================================
// Task 7: Breakout Detection
// ============================================================================
// detectBoxBreakout checks if price has broken out of any box level
// Returns the highest breakout level and direction
func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) {
if box == nil {
return market.BreakoutNone, ""
}
price := box.CurrentPrice
// Check long box first (highest priority)
if price > box.LongUpper {
return market.BreakoutLong, "up"
}
if price < box.LongLower {
return market.BreakoutLong, "down"
}
// Check mid box
if price > box.MidUpper {
return market.BreakoutMid, "up"
}
if price < box.MidLower {
return market.BreakoutMid, "down"
}
// Check short box
if price > box.ShortUpper {
return market.BreakoutShort, "up"
}
if price < box.ShortLower {
return market.BreakoutShort, "down"
}
return market.BreakoutNone, ""
}
// ============================================================================
// Task 8: Breakout Confirmation Logic
// ============================================================================
const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout
// BreakoutState tracks the current breakout state
type BreakoutState struct {
Level market.BreakoutLevel
Direction string
ConfirmCount int
StartTime time.Time
}
// confirmBreakout updates breakout state and returns true if breakout is confirmed
func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool {
// If price returned to box, reset state
if currentLevel == market.BreakoutNone {
state.ConfirmCount = 0
state.Level = market.BreakoutNone
state.Direction = ""
return false
}
// If same breakout continues, increment count
if state.Level == currentLevel && state.Direction == direction {
state.ConfirmCount++
} else {
// New breakout, reset count
state.Level = currentLevel
state.Direction = direction
state.ConfirmCount = 1
state.StartTime = time.Now()
}
return state.ConfirmCount >= BreakoutConfirmRequired
}
// ============================================================================
// Task 9: Breakout Handler
// ============================================================================
// BreakoutAction represents the action to take on breakout
type BreakoutAction int
const (
BreakoutActionNone BreakoutAction = iota
BreakoutActionReducePosition // Short box breakout: reduce to 50%
BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders
BreakoutActionCloseAll // Long box breakout: pause + cancel + close all
)
// getBreakoutAction returns the appropriate action for a breakout level
func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
switch level {
case market.BreakoutShort:
return BreakoutActionReducePosition
case market.BreakoutMid:
return BreakoutActionPauseGrid
case market.BreakoutLong:
return BreakoutActionCloseAll
default:
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
}

342
trader/grid_regime_test.go Normal file
View File

@@ -0,0 +1,342 @@
package trader
import (
"nofx/market"
"testing"
)
func TestClassifyRegimeLevel(t *testing.T) {
tests := []struct {
name string
bollingerWidth float64
atr14Pct float64
expected market.RegimeLevel
}{
{"narrow", 1.5, 0.8, market.RegimeLevelNarrow},
{"standard", 2.5, 1.5, market.RegimeLevelStandard},
{"wide", 3.5, 2.5, market.RegimeLevelWide},
{"volatile", 5.0, 4.0, market.RegimeLevelVolatile},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestDetectBoxBreakout(t *testing.T) {
box := &market.BoxData{
ShortUpper: 100,
ShortLower: 90,
MidUpper: 105,
MidLower: 85,
LongUpper: 110,
LongLower: 80,
CurrentPrice: 95,
}
// No breakout
level, direction := detectBoxBreakout(box)
if level != market.BreakoutNone {
t.Errorf("Expected no breakout, got %v", level)
}
// Short breakout up
box.CurrentPrice = 101
level, direction = detectBoxBreakout(box)
if level != market.BreakoutShort || direction != "up" {
t.Errorf("Expected short breakout up, got %v %v", level, direction)
}
// Mid breakout down
box.CurrentPrice = 84
level, direction = detectBoxBreakout(box)
if level != market.BreakoutMid || direction != "down" {
t.Errorf("Expected mid breakout down, got %v %v", level, direction)
}
// Long breakout up
box.CurrentPrice = 112
level, direction = detectBoxBreakout(box)
if level != market.BreakoutLong || direction != "up" {
t.Errorf("Expected long breakout up, got %v %v", level, direction)
}
}
func TestBreakoutConfirmation(t *testing.T) {
state := &BreakoutState{
Level: market.BreakoutNone,
Direction: "",
ConfirmCount: 0,
}
// First detection
confirmed := confirmBreakout(state, market.BreakoutShort, "up")
if confirmed || state.ConfirmCount != 1 {
t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
}
// Second confirmation
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
if confirmed || state.ConfirmCount != 2 {
t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
}
// Third confirmation - should confirm
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
if !confirmed || state.ConfirmCount != 3 {
t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
}
// Reset on price return
state.ConfirmCount = 2
confirmed = confirmBreakout(state, market.BreakoutNone, "")
if state.ConfirmCount != 0 {
t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount)
}
}
func TestGetBreakoutAction(t *testing.T) {
tests := []struct {
level market.BreakoutLevel
expected BreakoutAction
}{
{market.BreakoutNone, BreakoutActionNone},
{market.BreakoutShort, BreakoutActionReducePosition},
{market.BreakoutMid, BreakoutActionPauseGrid},
{market.BreakoutLong, BreakoutActionCloseAll},
}
for _, tt := range tests {
t.Run(string(tt.level), func(t *testing.T) {
action := getBreakoutAction(tt.level)
if action != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, action)
}
})
}
}
// ============================================================================
// 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 ( import (
"os" "os"

View File

@@ -1,4 +1,4 @@
package trader package hyperliquid
import ( import (
"fmt" "fmt"
@@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time) return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -61,7 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
positionSide = "SHORT" positionSide = "SHORT"
} }
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -78,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -89,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -106,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -118,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1,4 +1,4 @@
package trader package hyperliquid
import ( import (
"math" "math"
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "open_long", symbol, "LONG", "open_long",
0.1, 3500, 0.5, 0, 0.1, 3500, 0.5, 0,
time.Now(), "order-1", time.Now().UnixMilli(), "order-1",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process open long: %v", err) t.Fatalf("Failed to process open long: %v", err)
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "close_long", symbol, "LONG", "close_long",
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10 0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
time.Now(), "order-2", time.Now().UnixMilli(), "order-2",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process close long: %v", err) t.Fatalf("Failed to process close long: %v", err)
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "SHORT", "open_short", symbol, "SHORT", "open_short",
0.05, 3500, 0.25, 0, 0.05, 3500, 0.25, 0,
time.Now(), "order-3", time.Now().UnixMilli(), "order-3",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process open short: %v", err) t.Fatalf("Failed to process open short: %v", err)
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "SHORT", "close_short", symbol, "SHORT", "close_short",
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5 0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
time.Now(), "order-4", time.Now().UnixMilli(), "order-4",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process close short: %v", err) t.Fatalf("Failed to process close short: %v", err)
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "open_long", symbol, "LONG", "open_long",
0.1, 3500, 0.5, 0, 0.1, 3500, 0.5, 0,
time.Now(), "order-5", time.Now().UnixMilli(), "order-5",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process first open: %v", err) t.Fatalf("Failed to process first open: %v", err)
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "open_long", symbol, "LONG", "open_long",
0.1, 3600, 0.5, 0, 0.1, 3600, 0.5, 0,
time.Now(), "order-6", time.Now().UnixMilli(), "order-6",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process add position: %v", err) t.Fatalf("Failed to process add position: %v", err)
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "close_long", symbol, "LONG", "close_long",
0.2, 3700, 1.0, 30.0, 0.2, 3700, 1.0, 30.0,
time.Now(), "order-7", time.Now().UnixMilli(), "order-7",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process close: %v", err) t.Fatalf("Failed to process close: %v", err)
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "open_long", symbol, "LONG", "open_long",
1.0, 3500, 2.0, 0, 1.0, 3500, 2.0, 0,
time.Now(), "order-8", time.Now().UnixMilli(), "order-8",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process open: %v", err) t.Fatalf("Failed to process open: %v", err)
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, "LONG", "close_long", symbol, "LONG", "close_long",
0.3, 3600, 0.6, 30.0, 0.3, 3600, 0.6, 30.0,
time.Now(), "order-9", time.Now().UnixMilli(), "order-9",
) )
if err != nil { if err != nil {
t.Fatalf("Failed to process partial close: %v", err) t.Fatalf("Failed to process partial close: %v", err)
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
trade.symbol, trade.side, trade.action, trade.symbol, trade.side, trade.action,
trade.qty, trade.price, trade.fee, trade.pnl, trade.qty, trade.price, trade.fee, trade.pnl,
time.Now().Add(time.Duration(i)*time.Second), time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
"", "",
) )
if err != nil { if err != nil {

View File

@@ -1,4 +1,4 @@
package trader package hyperliquid
import ( import (
"bytes" "bytes"
@@ -16,16 +16,18 @@ import (
"github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid" "github.com/sonirico/go-hyperliquid"
"nofx/trader/types"
) )
// HyperliquidTrader Hyperliquid trader // HyperliquidTrader Hyperliquid trader
type HyperliquidTrader struct { type HyperliquidTrader struct {
exchange *hyperliquid.Exchange exchange *hyperliquid.Exchange
ctx context.Context ctx context.Context
walletAddr string walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision) meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode isCrossMargin bool // Whether to use cross margin mode
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
// xyz dex support (stocks, forex, commodities) // xyz dex support (stocks, forex, commodities)
xyzMeta *xyzDexMeta xyzMeta *xyzDexMeta
xyzMetaMutex sync.RWMutex xyzMetaMutex sync.RWMutex
@@ -79,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
} }
// NewHyperliquidTrader creates a Hyperliquid trader // NewHyperliquidTrader creates a Hyperliquid trader
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) { // unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
// Remove 0x prefix from private key (if present, case-insensitive) // Remove 0x prefix from private key (if present, case-insensitive)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x") privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
@@ -174,14 +177,19 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
} }
} }
if unifiedAccount {
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
}
return &HyperliquidTrader{ return &HyperliquidTrader{
exchange: exchange, exchange: exchange,
ctx: ctx, ctx: ctx,
walletAddr: walletAddr, walletAddr: walletAddr,
meta: meta, meta: meta,
isCrossMargin: true, // Use cross margin mode by default isCrossMargin: true, // Use cross margin mode by default
privateKey: privateKey, isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
isTestnet: testnet, privateKey: privateKey,
isTestnet: testnet,
}, nil }, nil
} }
@@ -249,7 +257,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL) // AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only) // 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" // Need to return "wallet balance without unrealized PnL"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
@@ -303,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this // Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perpetuals only) result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz) result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities) result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
@@ -1402,15 +1419,12 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
}, },
} }
// Create OrderAction with builder (xyz dex requires builder info for order routing) // Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{ action := hyperliquid.OrderAction{
Type: "order", Type: "order",
Orders: []hyperliquid.OrderWire{orderWire}, Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na", Grouping: "na",
Builder: &hyperliquid.BuilderInfo{ Builder: nil,
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
},
} }
// Sign the action // Sign the action
@@ -1592,15 +1606,12 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
}, },
} }
// Create OrderAction with builder // Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{ action := hyperliquid.OrderAction{
Type: "order", Type: "order",
Orders: []hyperliquid.OrderWire{orderWire}, Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na", Grouping: "na",
Builder: &hyperliquid.BuilderInfo{ Builder: nil,
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
},
} }
// Sign the action // Sign the action
@@ -1956,14 +1967,14 @@ func absFloat(x float64) float64 {
// GetClosedPnL gets recent closing trades from Hyperliquid // GetClosedPnL gets recent closing trades from Hyperliquid
// Note: Hyperliquid does NOT have a position history API, only fill history. // Note: Hyperliquid does NOT have a position history API, only fill history.
// This returns individual closing trades for real-time position closure detection. // 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) trades, err := t.GetTrades(startTime, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Filter only closing trades (realizedPnl != 0) // Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord var records []types.ClosedPnLRecord
for _, trade := range trades { for _, trade := range trades {
if trade.RealizedPnL == 0 { if trade.RealizedPnL == 0 {
continue continue
@@ -1987,7 +1998,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
} }
} }
records = append(records, ClosedPnLRecord{ records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol, Symbol: trade.Symbol,
Side: side, Side: side,
EntryPrice: entryPrice, EntryPrice: entryPrice,
@@ -2007,7 +2018,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
} }
// GetTrades retrieves trade history from Hyperliquid // 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 // Use UserFillsByTime API
startTimeMs := startTime.UnixMilli() startTimeMs := startTime.UnixMilli()
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil) fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
@@ -2015,7 +2026,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
return nil, fmt.Errorf("failed to get user fills: %w", err) return nil, fmt.Errorf("failed to get user fills: %w", err)
} }
var trades []TradeRecord var trades []types.TradeRecord
for _, fill := range fills { for _, fill := range fills {
price, _ := strconv.ParseFloat(fill.Price, 64) price, _ := strconv.ParseFloat(fill.Price, 64)
qty, _ := strconv.ParseFloat(fill.Size, 64) qty, _ := strconv.ParseFloat(fill.Size, 64)
@@ -2060,7 +2071,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
} }
// Hyperliquid uses one-way mode, so PositionSide is "BOTH" // Hyperliquid uses one-way mode, so PositionSide is "BOTH"
trade := TradeRecord{ trade := types.TradeRecord{
TradeID: strconv.FormatInt(fill.Tid, 10), TradeID: strconv.FormatInt(fill.Tid, 10),
Symbol: fill.Coin, Symbol: fill.Coin,
Side: side, Side: side,
@@ -2070,7 +2081,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(fill.Time), Time: time.UnixMilli(fill.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -2079,7 +2090,159 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
} }
// defaultBuilder is the builder info for order routing // defaultBuilder is the builder info for order routing
var defaultBuilder = &hyperliquid.BuilderInfo{ // Set to nil to avoid requiring builder fee approval
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", //
Fee: 10, // var defaultBuilder = &hyperliquid.BuilderInfo{
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
// Fee: 10,
// }
var defaultBuilder *hyperliquid.BuilderInfo = nil
// GetOpenOrders gets all open/pending orders for a symbol
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 []types.OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
}
side := "BUY"
if order.Side == "A" {
side = "SELL"
}
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
PositionSide: "",
Type: "LIMIT",
Price: order.LimitPx,
StopPrice: 0,
Quantity: order.Size,
Status: "NEW",
})
}
return result, nil
}
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
coin := convertSymbolToHyperliquid(req.Symbol)
// Set leverage if specified and not xyz dex
isXyz := strings.HasPrefix(coin, "xyz:")
if req.Leverage > 0 && !isXyz {
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
}
}
// Round quantity to allowed decimals
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
// Round price to 5 significant figures
roundedPrice := t.roundPriceToSigfigs(req.Price)
// Determine if buy or sell
isBuy := req.Side == "BUY"
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
order := hyperliquid.CreateOrderRequest{
Coin: coin,
IsBuy: isBuy,
Size: roundedQuantity,
Price: roundedPrice,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
},
},
ReduceOnly: req.ReduceOnly,
}
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
if err != nil {
return nil, fmt.Errorf("failed to place limit order: %w", err)
}
// Note: Hyperliquid's Order response doesn't return the order ID directly
// We would need to query open orders to get it, but for grid trading
// we can track orders by price level instead
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
coin, req.Side, roundedPrice)
return &types.LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,
Side: req.Side,
PositionSide: req.PositionSide,
Price: roundedPrice,
Quantity: roundedQuantity,
Status: "NEW",
}, nil
}
// CancelOrder cancels a specific order by ID
// Implements GridTrader interface
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
coin := convertSymbolToHyperliquid(symbol)
// Parse order ID
oid, err := strconv.ParseInt(orderID, 10, 64)
if err != nil {
return fmt.Errorf("invalid order ID: %w", err)
}
_, err = t.exchange.Cancel(t.ctx, coin, oid)
if err != nil {
return fmt.Errorf("failed to cancel order: %w", err)
}
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
return nil
}
// GetOrderBook gets the order book for a symbol
// Implements GridTrader interface
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
coin := convertSymbolToHyperliquid(symbol)
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
if err != nil {
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
}
if l2Book == nil || len(l2Book.Levels) < 2 {
return nil, nil, fmt.Errorf("invalid order book data")
}
// Parse bids (first level array)
for i, level := range l2Book.Levels[0] {
if i >= depth {
break
}
bids = append(bids, []float64{level.Px, level.Sz})
}
// Parse asks (second level array)
for i, level := range l2Book.Levels[1] {
if i >= depth {
break
}
asks = append(asks, []float64{level.Px, level.Sz})
}
return bids, asks, nil
} }

View File

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

View File

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

View File

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

878
trader/indodax/trader.go Normal file
View File

@@ -0,0 +1,878 @@
package indodax
import (
"crypto/hmac"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"net/url"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
"time"
)
// Indodax API endpoints
const (
indodaxBaseURL = "https://indodax.com"
indodaxPublicAPI = "/api"
indodaxPrivateAPI = "/tapi"
)
// IndodaxTrader implements types.Trader interface for Indodax Spot Exchange
// Indodax is Indonesia's largest crypto exchange, supporting IDR (Indonesian Rupiah) pairs.
// Since Indodax is spot-only, futures-specific methods (OpenShort, CloseShort, leverage, etc.)
// are gracefully stubbed.
type IndodaxTrader struct {
apiKey string
secretKey string
httpClient *http.Client
nonce int64
nonceMutex sync.Mutex
// Cache for pair info
pairCache map[string]*IndodaxPair
pairCacheMutex sync.RWMutex
pairCacheTime time.Time
// Cache for balance
cachedBalance map[string]interface{}
cachedPositions []map[string]interface{}
balanceCacheTime time.Time
positionCacheTime time.Time
cacheDuration time.Duration
cacheMutex sync.RWMutex
}
// IndodaxPair represents a trading pair on Indodax
type IndodaxPair struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
BaseCurrency string `json:"base_currency"`
TradedCurrency string `json:"traded_currency"`
TradedCurrencyUnit string `json:"traded_currency_unit"`
Description string `json:"description"`
TickerID string `json:"ticker_id"`
VolumePrecision int `json:"volume_precision"`
PricePrecision float64 `json:"price_precision"`
PriceRound int `json:"price_round"`
Pricescale float64 `json:"pricescale"`
TradeMinBaseCurrency float64 `json:"trade_min_base_currency"`
TradeMinTradedCurrency float64 `json:"trade_min_traded_currency"`
}
// IndodaxResponse represents the standard Indodax private API response
type IndodaxResponse struct {
Success int `json:"success"`
Return json.RawMessage `json:"return,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
// IndodaxTicker represents ticker data
type IndodaxTicker struct {
High string `json:"high"`
Low string `json:"low"`
Last string `json:"last"`
Buy string `json:"buy"`
Sell string `json:"sell"`
ServerTime int64 `json:"server_time"`
}
// IndodaxTickerResponse wraps ticker response
type IndodaxTickerResponse struct {
Ticker IndodaxTicker `json:"ticker"`
}
// NewIndodaxTrader creates a new Indodax trader instance
func NewIndodaxTrader(apiKey, secretKey string) *IndodaxTrader {
return &IndodaxTrader{
apiKey: apiKey,
secretKey: secretKey,
httpClient: &http.Client{Timeout: 30 * time.Second},
nonce: time.Now().UnixMilli(),
pairCache: make(map[string]*IndodaxPair),
cacheDuration: 15 * time.Second,
}
}
// getNonce returns a unique incrementing nonce for each request
func (t *IndodaxTrader) getNonce() int64 {
t.nonceMutex.Lock()
defer t.nonceMutex.Unlock()
t.nonce++
return t.nonce
}
// sign generates HMAC-SHA512 signature for request body
func (t *IndodaxTrader) sign(body string) string {
mac := hmac.New(sha512.New, []byte(t.secretKey))
mac.Write([]byte(body))
return hex.EncodeToString(mac.Sum(nil))
}
// doPublicRequest makes a public API GET request
func (t *IndodaxTrader) doPublicRequest(path string) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPublicAPI + path
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
// doPrivateRequest makes a signed private API POST request
func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
reqURL := indodaxBaseURL + indodaxPrivateAPI
// Add nonce
params.Set("nonce", strconv.FormatInt(t.getNonce(), 10))
body := params.Encode()
signature := t.sign(body)
req, err := http.NewRequest("POST", reqURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Key", t.apiKey)
req.Header.Set("Sign", signature)
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests {
return nil, fmt.Errorf("rate limit exceeded, please try again later")
}
// Parse response to check success
var apiResp IndodaxResponse
if err := json.Unmarshal(data, &apiResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(data))
}
if apiResp.Success != 1 {
return nil, fmt.Errorf("API error: %s (code: %s)", apiResp.Error, apiResp.ErrorCode)
}
return apiResp.Return, nil
}
// convertSymbol converts standard symbol to Indodax format
// e.g. BTCIDR -> btc_idr, ETHIDR -> eth_idr
func (t *IndodaxTrader) convertSymbol(symbol string) string {
s := strings.ToLower(symbol)
// Already in Indodax format (contains underscore)
if strings.Contains(s, "_") {
return s
}
// Try to split by known base currencies
for _, base := range []string{"idr", "btc", "usdt"} {
if strings.HasSuffix(s, base) {
traded := strings.TrimSuffix(s, base)
if traded != "" {
return traded + "_" + base
}
}
}
return s
}
// convertSymbolBack converts Indodax format back to standard
// e.g. btc_idr -> BTCIDR
func (t *IndodaxTrader) convertSymbolBack(indodaxSymbol string) string {
return strings.ToUpper(strings.ReplaceAll(indodaxSymbol, "_", ""))
}
// getCoinFromSymbol extracts the traded currency from a symbol
// e.g. btc_idr -> btc, eth_idr -> eth
func (t *IndodaxTrader) getCoinFromSymbol(symbol string) string {
pair := t.convertSymbol(symbol)
parts := strings.Split(pair, "_")
if len(parts) >= 1 {
return parts[0]
}
return strings.ToLower(symbol)
}
// loadPairs loads trading pair information from the public API
func (t *IndodaxTrader) loadPairs() error {
t.pairCacheMutex.RLock()
if len(t.pairCache) > 0 && time.Since(t.pairCacheTime) < 5*time.Minute {
t.pairCacheMutex.RUnlock()
return nil
}
t.pairCacheMutex.RUnlock()
data, err := t.doPublicRequest("/pairs")
if err != nil {
return fmt.Errorf("failed to load pairs: %w", err)
}
var pairs []IndodaxPair
if err := json.Unmarshal(data, &pairs); err != nil {
return fmt.Errorf("failed to parse pairs: %w", err)
}
t.pairCacheMutex.Lock()
defer t.pairCacheMutex.Unlock()
t.pairCache = make(map[string]*IndodaxPair)
for i := range pairs {
p := pairs[i]
t.pairCache[p.TickerID] = &p
// Also index by ID (e.g. "btcidr")
t.pairCache[p.ID] = &p
}
t.pairCacheTime = time.Now()
logger.Infof("[Indodax] Loaded %d trading pairs", len(pairs))
return nil
}
// getPair gets pair info for a symbol
func (t *IndodaxTrader) getPair(symbol string) (*IndodaxPair, error) {
if err := t.loadPairs(); err != nil {
return nil, err
}
pairID := t.convertSymbol(symbol)
t.pairCacheMutex.RLock()
defer t.pairCacheMutex.RUnlock()
if pair, ok := t.pairCache[pairID]; ok {
return pair, nil
}
// Try without underscore
noUnderscore := strings.ReplaceAll(pairID, "_", "")
if pair, ok := t.pairCache[noUnderscore]; ok {
return pair, nil
}
return nil, fmt.Errorf("pair not found: %s", symbol)
}
// clearCache clears cached data
func (t *IndodaxTrader) clearCache() {
t.cacheMutex.Lock()
defer t.cacheMutex.Unlock()
t.cachedBalance = nil
t.cachedPositions = nil
}
// ============================================================
// types.Trader interface implementation
// ============================================================
// GetBalance gets account balance from Indodax
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
cached := t.cachedBalance
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get account info: %w", err)
}
var result struct {
ServerTime int64 `json:"server_time"`
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
UserID string `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse balance: %w", err)
}
// Calculate total balance in IDR
idrBalance := parseFloat(result.Balance["idr"])
idrHold := parseFloat(result.BalanceHold["idr"])
totalIDR := idrBalance + idrHold
balance := map[string]interface{}{
"totalWalletBalance": totalIDR,
"availableBalance": idrBalance,
"totalUnrealizedProfit": 0.0,
"totalEquity": totalIDR,
"balance": totalIDR,
"idr_balance": idrBalance,
"idr_hold": idrHold,
"currency": "IDR",
"user_id": result.UserID,
"server_time": result.ServerTime,
}
// Add individual crypto balances
for currency, amount := range result.Balance {
if currency != "idr" {
balance["balance_"+currency] = parseFloat(amount)
}
}
for currency, amount := range result.BalanceHold {
if currency != "idr" {
balance["hold_"+currency] = parseFloat(amount)
}
}
// Update cache
t.cacheMutex.Lock()
t.cachedBalance = balance
t.balanceCacheTime = time.Now()
t.cacheMutex.Unlock()
return balance, nil
}
// GetPositions returns currently held crypto balances as "positions"
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
// Check cache
t.cacheMutex.RLock()
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
cached := t.cachedPositions
t.cacheMutex.RUnlock()
return cached, nil
}
t.cacheMutex.RUnlock()
params := url.Values{}
params.Set("method", "getInfo")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
var result struct {
Balance map[string]interface{} `json:"balance"`
BalanceHold map[string]interface{} `json:"balance_hold"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse positions: %w", err)
}
var positions []map[string]interface{}
for currency, amountRaw := range result.Balance {
if currency == "idr" {
continue
}
amount := parseFloat(amountRaw)
holdAmount := parseFloat(result.BalanceHold[currency])
totalAmount := amount + holdAmount
if totalAmount <= 0 {
continue
}
// Get market price for this coin
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
// Calculate position value in IDR
notionalValue := totalAmount * markPrice
position := map[string]interface{}{
"symbol": strings.ToUpper(currency) + "IDR",
"side": "LONG",
"positionAmt": totalAmount,
"entryPrice": markPrice, // Spot doesn't track entry price
"markPrice": markPrice,
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
"leverage": 1.0,
"mgnMode": "spot",
"notionalValue": notionalValue,
"currency": currency,
"available": amount,
"hold": holdAmount,
}
positions = append(positions, position)
}
// Update cache
t.cacheMutex.Lock()
t.cachedPositions = positions
t.positionCacheTime = time.Now()
t.cacheMutex.Unlock()
return positions, nil
}
// OpenLong opens a spot buy order
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// Get market price to calculate IDR amount
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "buy")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place buy order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "BUY",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// OpenShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// CloseLong closes a spot position by selling
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
t.clearCache()
pair := t.convertSymbol(symbol)
coin := t.getCoinFromSymbol(symbol)
// If quantity is 0, sell all available balance
if quantity <= 0 {
balance, err := t.GetBalance()
if err != nil {
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
}
available := parseFloat(balance["balance_"+coin])
if available <= 0 {
return nil, fmt.Errorf("no %s balance to sell", coin)
}
quantity = available
}
// Get market price
price, err := t.GetMarketPrice(symbol)
if err != nil {
return nil, fmt.Errorf("failed to get market price: %w", err)
}
params := url.Values{}
params.Set("method", "trade")
params.Set("pair", pair)
params.Set("type", "sell")
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
params.Set("order_type", "limit")
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to place sell order: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse trade response: %w", err)
}
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
return map[string]interface{}{
"orderId": result["order_id"],
"symbol": symbol,
"side": "SELL",
"price": price,
"qty": quantity,
"status": "NEW",
}, nil
}
// CloseShort is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
}
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
return nil
}
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
return nil
}
// GetMarketPrice gets the current market price for a symbol
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
data, err := t.doPublicRequest("/ticker/" + pairID)
if err != nil {
return 0, fmt.Errorf("failed to get ticker: %w", err)
}
var tickerResp IndodaxTickerResponse
if err := json.Unmarshal(data, &tickerResp); err != nil {
return 0, fmt.Errorf("failed to parse ticker: %w", err)
}
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
}
return price, nil
}
// SetStopLoss is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
}
// SetTakeProfit is not supported on Indodax (spot-only exchange)
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
}
// CancelStopLossOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
return nil
}
// CancelTakeProfitOrders is a no-op for Indodax
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
// CancelAllOrders cancels all open orders for a given symbol
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
t.clearCache()
pair := t.convertSymbol(symbol)
// First get open orders
params := url.Values{}
params.Set("method", "openOrders")
params.Set("pair", pair)
data, err := t.doPrivateRequest(params)
if err != nil {
return fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("failed to parse open orders: %w", err)
}
// Cancel each order
for _, order := range result.Orders {
cancelParams := url.Values{}
cancelParams.Set("method", "cancelOrder")
cancelParams.Set("pair", pair)
cancelParams.Set("order_id", order.OrderID.String())
cancelParams.Set("type", order.Type)
if _, err := t.doPrivateRequest(cancelParams); err != nil {
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
} else {
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
}
}
return nil
}
// CancelStopOrders is a no-op for Indodax (no stop orders)
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
return nil
}
// FormatQuantity formats quantity to correct precision for Indodax
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
pair, err := t.getPair(symbol)
if err != nil {
// Default: 8 decimal places
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
}
precision := pair.PriceRound
if precision <= 0 {
precision = 8
}
// Round down to avoid exceeding balance
factor := math.Pow(10, float64(precision))
rounded := math.Floor(quantity*factor) / factor
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
}
// GetOrderStatus gets the status of a specific order
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "getOrder")
params.Set("pair", pair)
params.Set("order_id", orderID)
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get order status: %w", err)
}
var result struct {
Order struct {
OrderID string `json:"order_id"`
Price string `json:"price"`
Type string `json:"type"`
Status string `json:"status"`
SubmitTime string `json:"submit_time"`
FinishTime string `json:"finish_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"order"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse order: %w", err)
}
// Map Indodax status to standard status
status := "NEW"
switch result.Order.Status {
case "filled":
status = "FILLED"
case "cancelled":
status = "CANCELED"
case "open":
status = "NEW"
}
price, _ := strconv.ParseFloat(result.Order.Price, 64)
return map[string]interface{}{
"status": status,
"avgPrice": price,
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
"commission": 0.0,
"orderId": result.Order.OrderID,
}, nil
}
// GetClosedPnL gets closed position PnL records (trade history)
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Indodax trade history is limited to 7 days range
params := url.Values{}
params.Set("method", "tradeHistory")
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
if limit > 0 {
params.Set("count", strconv.Itoa(limit))
}
if !startTime.IsZero() {
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get trade history: %w", err)
}
var result struct {
Trades []struct {
TradeID string `json:"trade_id"`
OrderID string `json:"order_id"`
Type string `json:"type"`
Price string `json:"price"`
Fee string `json:"fee"`
TradeTime string `json:"trade_time"`
ClientOrderID string `json:"client_order_id"`
} `json:"trades"`
}
if err := json.Unmarshal(data, &result); err != nil {
// Trade history might return empty, that's fine
return nil, nil
}
var records []types.ClosedPnLRecord
for _, trade := range result.Trades {
price, _ := strconv.ParseFloat(trade.Price, 64)
fee, _ := strconv.ParseFloat(trade.Fee, 64)
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
side := "long"
if trade.Type == "sell" {
side = "long" // Selling from a spot position is closing long
}
records = append(records, types.ClosedPnLRecord{
Symbol: "BTCIDR",
Side: side,
ExitPrice: price,
Fee: fee,
ExitTime: time.Unix(tradeTime, 0),
OrderID: trade.OrderID,
CloseType: "manual",
})
}
return records, nil
}
// GetOpenOrders gets open/pending orders
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
pair := t.convertSymbol(symbol)
params := url.Values{}
params.Set("method", "openOrders")
if pair != "" {
params.Set("pair", pair)
}
data, err := t.doPrivateRequest(params)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result struct {
Orders []struct {
OrderID json.Number `json:"order_id"`
ClientOrderID string `json:"client_order_id"`
SubmitTime string `json:"submit_time"`
Price string `json:"price"`
Type string `json:"type"`
OrderType string `json:"order_type"`
} `json:"orders"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var orders []types.OpenOrder
for _, order := range result.Orders {
price, _ := strconv.ParseFloat(order.Price, 64)
side := "BUY"
if order.Type == "sell" {
side = "SELL"
}
orders = append(orders, types.OpenOrder{
OrderID: order.OrderID.String(),
Symbol: t.convertSymbolBack(pair),
Side: side,
PositionSide: "LONG",
Type: "LIMIT",
Price: price,
Status: "NEW",
})
}
return orders, nil
}
// ============================================================
// Helper functions
// ============================================================
// parseFloat safely parses a float from interface{}
func parseFloat(v interface{}) float64 {
if v == nil {
return 0
}
switch val := v.(type) {
case float64:
return val
case string:
f, _ := strconv.ParseFloat(val, 64)
return f
case json.Number:
f, _ := val.Float64()
return f
case int:
return float64(val)
case int64:
return float64(val)
default:
return 0
}
}

Some files were not shown because too many files have changed in this diff Show More