96 Commits

Author SHA1 Message Date
tinkle-community
a4a81993bb chore: simplify PR template (OpenClaw-style) 2026-03-11 15:57:03 +08:00
tinkle-community
b73617fed3 fix: stop PR template bot from overwriting user-written descriptions
The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.

Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md
2026-03-11 15:56:50 +08:00
tinkle-community
4774348ed6 refactor: centralize x402 payment flow into shared mcp/x402.go
Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.
2026-03-11 15:42:19 +08:00
tinklefund
e638ba8d8f feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html 2026-03-11 04:37:50 +08:00
tinkle-community
156bf04bcc feat: add Claw402 (claw402.ai) x402 USDC payment provider
Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.

- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
  api/server.go, api/strategy.go, trader/auto_trader.go,
  telegram/bot.go, debate/engine.go, backtest/ai_client.go
2026-03-10 17:53:13 +08:00
tinkle-community
af250825e7 feat: add blockRun (x402 USDC) support to all AI model consumers
- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
  clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
  over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient
2026-03-10 17:47:19 +08:00
tinkle-community
c5c5ed2a4d merge: resolve conflict in api/server.go (dev → openclaw)
Keep s.route() wrapper style from openclaw, add reset-password route from dev.
2026-03-10 17:04:46 +08:00
1bcMax
6f77ed2fcb feat: add BlockRun wallet provider for pay-per-request AI access (#1408)
Integrates BlockRun (blockrun.ai) as a new AI provider option via x402
micropayment protocol, allowing users to access top AI models with USDC
without requiring individual API keys.

- Add BlockRun Base (EVM) and Solana wallet providers to model selector
- Implement x402 v2 EIP-712 payment signing for Base (mcp/blockrun_base.go)
- Implement x402 v2 SPL TransferChecked signing for Solana (mcp/blockrun_sol.go)
- Wire blockrun-base and blockrun-sol into trader factory (auto_trader.go)
- Register both providers in supported models API (server.go)
- Add BlockRun card UI with wallet key input in Step 0/1 of model config modal
- Add BlockRun SVG icon and ModelIcons support
- Add setup guides for Base and Solana wallet configuration (docs/)
- Available flagship models: GPT-5.4, Claude Opus 4.6, Gemini 3.1 Pro,
  Grok 3, DeepSeek Chat, MiniMax M2.5
2026-03-10 14:54:50 +08:00
Hansen1018
034c206874 Update: replace minimax.svg with Minimax logo (#1407) 2026-03-10 13:51:03 +08:00
tinkle-community
fcb90b77ae Merge branch 'dev' into openclaw 2026-03-10 00:19:42 +08:00
tinkle-community
7b9a0740c1 fix(security): block SSRF via custom AI model URL
Apply security.ValidateURL() to custom_api_url in PUT /api/models before
storing — blocks private IPs, cloud metadata endpoints, and localhost.
Replace plain http.Client in mcp/config.go with security.SafeHTTPClient()
for defense-in-depth (DialContext blocks private IPs, CheckRedirect
validates targets). Add SSRF warning to WithHTTPClient() docs.
2026-03-10 00:14:01 +08:00
tinkle-community
3ed0aec0ff refactor: single-user web-based setup — replace env config with Settings UI
Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
2026-03-09 23:55:39 +08:00
ximi
8406f2f998 feat: add MiniMax provider support (#1406)
Add MiniMax as a new AI model provider with OpenAI-compatible API.

Supported models:
- MiniMax-M2.5 (default) - Peak Performance, Ultimate Value
- MiniMax-M2.5-highspeed - Same performance, faster and more agile

Changes:
- Add MiniMax client (mcp/minimax_client.go) with OpenAI-compatible API
- Add comprehensive unit tests (mcp/minimax_client_test.go)
- Add WithMiniMaxConfig option (mcp/options.go)
- Register MiniMax provider in trader, debate engine, backtest, and API
- Add MiniMax to frontend provider config and model icons
- Add MiniMax SVG icon

API Base URL: https://api.minimax.io/v1
2026-03-09 23:18:51 +08:00
tinkle-community
9a3017af6d refactor(telegram): clean onboarding — web UI for setup, Telegram for operations
- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI
2026-03-08 20:16:58 +08:00
tinkle-community
aebca4b16c fix(telegram): remove 'default' user fallback — resolve user dynamically
- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
  * called on every /start (handles: registered after bot launch)
  * called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)
2026-03-08 19:45:07 +08:00
tinkle-community
767d8629a3 fix(start.sh): translate all user-facing text to English
Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English
2026-03-08 19:20:36 +08:00
tinkle-community
ff1ca4460d fix(telegram): use Markdown rendering + simplify language selection condition
- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
  bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
  (lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)
2026-03-08 18:56:05 +08:00
tinkle-community
d160301359 feat(i18n): bilingual EN/ZH setup guide with language selection
store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)

telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
  Step 1: configure AI model (no model yet)
  Step 2: configure exchange (model OK, no exchange)
  Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual

Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.
2026-03-08 18:44:38 +08:00
tinkle-community
1bbd4b44ac feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands
start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch

telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
  - No AI model → explains how to configure, lists all providers
  - AI model OK but no exchange → guides to configure exchange via chat
  - All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
  so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)

telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat
2026-03-08 18:40:51 +08:00
tinkle-community
b2ce123df1 fix(auth): single-user deployment by default, no open registration
Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env

Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)

This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.
2026-03-08 18:32:50 +08:00
tinkle-community
97f309c9b5 fix(telegram): newLLMClient uses bound user's model, not any user's model
GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.

Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.
2026-03-08 18:31:05 +08:00
tinkle-community
13d70d2598 fix(telegram): clientForProvider returns correct client for all 7 providers
Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini
2026-03-08 17:54:47 +08:00
tinkle-community
138bbb1242 test(mcp): add ClaudeClient wire format tests
Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL
2026-03-08 17:44:50 +08:00
tinkle-community
ca87dbe3bb fix(telegram): fix claude client dispatch + strategy creation workflow
- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
  'claude' provider (was incorrectly falling back to DeepSeekClient which
  uses OpenAI wire format, breaking Anthropic API calls)

- api/server.go: fix scan_interval_minutes schema default (3, not 60);
  POST /api/strategies now clearly states config is OPTIONAL with complete
  working defaults; POST /api/traders removes redundant GET workflow note

- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
  without config (backend applies full working defaults automatically);
  only include config when user requests custom settings
2026-03-08 17:39:14 +08:00
tinkle-community
ea7b450a7e refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format
Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).

Changes:

mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why

mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
  now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up

mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
    * system prompt → top-level "system" field
    * tools: parameters → input_schema, no "type:function" wrapper
    * tool_choice "auto" → {"type":"auto"} object
    * assistant tool calls → content[{type:tool_use, id, name, input}]
    * role=tool results → role=user content[{type:tool_result,...}]
    * consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull

All mcp and agent tests pass.
2026-03-08 17:29:21 +08:00
tinkle-community
9fcf44af65 refactor(agent): replace XML api_call with native function calling
Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.
2026-03-08 17:10:07 +08:00
tinkle-community
5f47dd13db fix(telegram): eliminate narration, add full-setup workflow and tests
- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
  final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
  - No narration leaking to user (5 narration variants tested)
  - api_call tag never leaks to user
  - Full setup workflow: POST strategy → verify → POST trader → start
  - Start existing trader workflow
  - Max iterations safety, tag stripping, parser edge cases
2026-03-08 16:44:45 +08:00
tinkle-community
b354eb8bf2 Merge branch 'dev' into openclaw
# Conflicts:
#	web/src/components/TraderConfigModal.tsx
#	web/src/i18n/translations.ts
2026-03-08 00:40:15 +08:00
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
3168a18c0d feat(telegram): add AI agent bot with streaming and account context
- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection
2026-03-08 00:19:38 +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
181 changed files with 31791 additions and 4822 deletions

View File

@@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false
# Optional: External Services
# ===========================================
# Telegram notifications (optional)
# TELEGRAM_BOT_TOKEN=your-bot-token
# TELEGRAM_CHAT_ID=your-chat-id
DB_TYPE=postgres
DB_HOST=10.
DB_PORT=5432

View File

@@ -1,100 +1,50 @@
# Pull Request
## Summary
> **📋 Choose Specialized Template**
>
> 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)** - For Go/API/Trading changes
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
>
> **How to use?**
> - 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
- Problem:
- What changed:
- What did NOT change (scope boundary):
---
## Change Type
> **💡 Tip:** Recommended PR title format `type(scope): description`
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
- [ ] Bug fix
- [ ] Feature
- [ ] Refactoring
- [ ] Docs
- [ ] Security fix
- [ ] Chore / infra
---
## Scope
## 📝 Description
- [ ] Trading engine / strategies
- [ ] MCP / AI clients
- [ ] API / server
- [ ] Telegram bot / agent
- [ ] Web UI / frontend
- [ ] Config / deployment
- [ ] CI/CD / infra
<!-- Describe your changes in detail -->
---
## 🎯 Type of Change
- [ ] 🐛 Bug fix
- [ ] ✨ New feature
- [ ] 💥 Breaking change
- [ ] 📝 Documentation update
- [ ] 🎨 Code style update
- [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement
- [ ] ✅ Test update
- [ ] 🔧 Build/config change
- [ ] 🔒 Security fix
---
## 🔗 Related Issues
## Linked Issues
- Closes #
- Related to #
- Related #
---
## Testing
## 📋 Changes Made
What you verified and how:
<!-- List the specific changes made -->
-
-
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes
- [ ] Manual testing done (describe below)
---
## Security Impact
## 🧪 Testing
- Secrets/keys handling changed? (`Yes/No`)
- New/changed API endpoints? (`Yes/No`)
- User input validation affected? (`Yes/No`)
- [ ] Tested locally
- [ ] Tests pass
- [ ] Verified no existing functionality broke
## Compatibility
---
## ✅ Checklist
### Code Quality
- [ ] Code follows project style
- [ ] Self-review completed
- [ ] Comments added for complex logic
### Documentation
- [ ] Updated relevant documentation
### Git
- [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts
---
## 📚 Additional Notes
<!-- Any additional information or context -->
---
**By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0
---
🌟 **Thank you for your contribution!**
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, upgrade steps:

View File

@@ -1,22 +1,18 @@
name: PR Template Suggester
name: PR Labeler
on:
pull_request:
types: [opened, edited, synchronize]
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
issues: write
contents: read
jobs:
suggest-template:
label-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Analyze PR files and auto-apply template
- name: Analyze PR and apply labels
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -25,166 +21,72 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
let additions = 0, deletions = 0;
for (const file of files) {
const filename = file.filename.toLowerCase();
if (filename.endsWith('.go')) goFiles++;
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
else if (filename.endsWith('.md')) mdFiles++;
const name = file.filename.toLowerCase();
additions += file.additions || 0;
deletions += file.deletions || 0;
if (name.endsWith('.go')) goFiles++;
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
else if (name.endsWith('.md')) mdFiles++;
else otherFiles++;
}
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
if (totalFiles === 0) { console.log('No files changed'); return; }
if (totalFiles === 0) return;
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
// --- Scope label ---
const labels = [];
if (goFiles / totalFiles > 0.5) labels.push('backend');
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
else labels.push('fullstack');
if (goFiles / totalFiles > 0.5) {
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
} else if (mdFiles / totalFiles > 0.7) {
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
// --- Size label (like OpenClaw) ---
const totalChanged = additions + deletions;
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
let sizeLabel = 'size: XL';
if (totalChanged < 50) sizeLabel = 'size: XS';
else if (totalChanged < 200) sizeLabel = 'size: S';
else if (totalChanged < 500) sizeLabel = 'size: M';
else if (totalChanged < 1000) sizeLabel = 'size: L';
labels.push(sizeLabel);
// Ensure size labels exist
for (const sl of sizeLabels) {
try {
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
} catch (e) {
if (e.status === 404) {
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
}
}
}
const { data: pr } = await github.rest.pulls.get({
// Remove stale size labels
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
});
for (const cl of currentLabels) {
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
}).catch(() => {});
}
}
// Apply labels
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
issue_number: context.issue.number,
labels: labels,
});
const prBody = pr.body || '';
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
const usesGeneralTemplate = prBody.includes('Pull Request - General');
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
if (templateLabel) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [templateLabel]
});
console.log('Added label: ' + templateLabel);
} catch (error) {
console.log('Label might not exist, skipping...');
}
}
function isPRBodyEmpty(body) {
if (!body || body.trim().length < 100) return true;
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
if (hasEmptyDescription || hasEmptyChanges) return true;
const descMatch = body.match(/\*\*English:\*\*[|]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
if (!descMatch || descMatch[1].trim().length < 10) return true;
return false;
}
if (suggestedTemplate && usingDefaultTemplate) {
const shouldAutoApply = isPRBodyEmpty(prBody);
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
if (shouldAutoApply) {
try {
const { data: templateFile } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: templatePath,
ref: context.payload.pull_request.head.ref
});
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
body: templateContent
});
console.log('Auto-applied ' + suggestedTemplate + ' template');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
'✨ 您现在可以直接在PR描述中填写相关信息了\n\n' +
'✨ You can now fill in the relevant information in the PR description!';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: notifyComment
});
} catch (error) {
console.log('Failed to fetch or apply template: ' + error.message);
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fallbackComment
});
}
} else {
console.log('PR body has content, sending suggestion only');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
'**如何使用 | How to use**\n' +
'1. 编辑PR描述 | Edit PR description\n' +
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
' `?template=' + suggestedTemplate + '.md`\n\n' +
'_这是一个自动建议您可以继续使用当前模板。_\n\n' +
'_This is an automated suggestion. You may continue using the current template._';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
} else if (suggestedTemplate && !usingDefaultTemplate) {
console.log('PR already uses a specific template');
} else {
console.log('No specific template suggestion needed - mixed changes');
}
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ nofx_test
# Go 相关
*.test
*.out
.gocache/
# 操作系统
.DS_Store

View File

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

View File

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

View File

@@ -832,6 +832,8 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
provider = "google"
} else if strings.Contains(modelNameLower, "deepseek") {
provider = "deepseek"
} else if strings.Contains(modelNameLower, "minimax") {
provider = "minimax"
} else if model.CustomAPIURL != "" {
provider = "custom"
} else {

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)
})
}
}

66
api/route_registry.go Normal file
View File

@@ -0,0 +1,66 @@
package api
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// RouteDoc holds documentation for a single API route.
type RouteDoc struct {
Method string
Path string
Description string
Schema string // optional: full parameter/body schema documentation
}
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
var routeRegistry []RouteDoc
// route registers an HTTP route with a one-line description.
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
s.routeWithSchema(g, method, path, description, "", h)
}
// routeWithSchema registers an HTTP route with full parameter schema documentation.
// schema is injected verbatim into the API docs seen by the LLM.
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
routeRegistry = append(routeRegistry, RouteDoc{
Method: method,
Path: fullPath,
Description: description,
Schema: schema,
})
switch method {
case "GET":
g.GET(path, h)
case "POST":
g.POST(path, h)
case "PUT":
g.PUT(path, h)
case "DELETE":
g.DELETE(path, h)
}
}
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
// Routes with schema documentation include full parameter details.
func GetAPIDocs() string {
var sb strings.Builder
for _, r := range routeRegistry {
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
if r.Schema != "" {
// Indent each schema line for readability
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
sb.WriteString(" ")
sb.WriteString(line)
sb.WriteByte('\n')
}
}
sb.WriteByte('\n')
}
return sb.String()
}

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
})
}
// handleCreateStrategy Create strategy
// handleCreateStrategy Create strategy.
// If "config" is omitted from the request body, the system default config is used automatically.
func (s *Server) handleCreateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
@@ -145,9 +146,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
return
}
// Use default config when none provided
if req.Config == nil {
lang := req.Lang
if lang == "" {
lang = "zh"
}
defaultCfg := store.GetDefaultStrategyConfig(lang)
req.Config = &defaultCfg
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
@@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
warnings := validateStrategyConfig(req.Config)
response := gin.H{
"id": strategy.ID,
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// handleUpdateStrategy Update strategy
// handleUpdateStrategy Update strategy.
// The incoming config is merged with the existing one: top-level sections present in the
// request overwrite the corresponding existing sections; absent sections are preserved.
// This prevents partial updates from zeroing out unmentioned fields.
func (s *Server) handleUpdateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
@@ -213,11 +228,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config"`
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
Name string `json:"name"`
Description string `json:"description"`
Config json.RawMessage `json:"config"` // raw JSON so we can merge
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
// Start with the existing config as base — preserves all unmentioned fields.
var mergedConfig store.StrategyConfig
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
// If existing config is corrupt, start from zero
mergedConfig = store.StrategyConfig{}
}
// Apply incoming config on top: top-level sections present in the request overwrite
// their corresponding existing section; absent sections remain unchanged.
if len(req.Config) > 0 && string(req.Config) != "null" {
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
SafeBadRequest(c, "Invalid config JSON")
return
}
}
// Preserve existing name/description when not supplied
name := req.Name
if name == "" {
name = existing.Name
}
description := req.Description
if description == "" {
description = existing.Description
}
configJSON, err := json.Marshal(mergedConfig)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
@@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
strategy := &store.Strategy{
ID: strategyID,
UserID: userID,
Name: req.Name,
Description: req.Description,
Name: name,
Description: description,
Config: string(configJSON),
IsPublic: req.IsPublic,
ConfigVisible: req.ConfigVisible,
@@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -625,6 +665,18 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
case "openai":
aiClient = mcp.NewOpenAIClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "minimax":
aiClient = mcp.NewMiniMaxClient()
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
case "blockrun-base":
aiClient = mcp.NewBlockRunBaseClient()
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
case "blockrun-sol":
aiClient = mcp.NewBlockRunSolClient()
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
case "claw402":
aiClient = mcp.NewClaw402Client()
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
default:
// Use generic client
aiClient = mcp.NewClient()

View File

@@ -1,15 +1,12 @@
package auth
import (
"crypto/rand"
"fmt"
"log"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
// maxBlacklistEntries is the maximum capacity threshold for blacklist
const maxBlacklistEntries = 100_000
// OTPIssuer is the OTP issuer name
const OTPIssuer = "nofxAI"
// SetJWTSecret sets the JWT secret key
func SetJWTSecret(secret string) {
JWTSecret = []byte(secret)
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
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
func GenerateJWT(userID, email string) (string, error) {
claims := Claims{
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
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

@@ -71,6 +71,34 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
oaiC := mcp.NewOpenAIClientWithOptions()
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return oaiC, nil
case "minimax":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("minimax provider requires api key")
}
mmC := mcp.NewMiniMaxClientWithOptions()
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
return mmC, nil
case "blockrun-base":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("blockrun-base provider requires wallet private key")
}
brBase := mcp.NewBlockRunBaseClient()
brBase.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
return brBase, nil
case "blockrun-sol":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("blockrun-sol provider requires wallet keypair")
}
brSol := mcp.NewBlockRunSolClient()
brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
return brSol, nil
case "claw402":
if cfg.AICfg.APIKey == "" {
return nil, fmt.Errorf("claw402 provider requires wallet private key")
}
claw := mcp.NewClaw402Client()
claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
return claw, nil
case "custom":
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
@@ -125,6 +153,11 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
cp := *c.Client
return &cp
}
case *mcp.MiniMaxClient:
if c != nil && c.Client != nil {
cp := *c.Client
return &cp
}
}
// Fall back to a new default client
return mcp.NewClient().(*mcp.Client)

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

@@ -15,10 +15,8 @@ var global *Config
// Only contains truly global config, trading related config is at trader/strategy level
type Config struct {
// Service configuration
APIServerPort int
JWTSecret string
RegistrationEnabled bool
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
APIServerPort int
JWTSecret string
// Database configuration
DBType string // sqlite or postgres
@@ -44,14 +42,13 @@ type Config struct {
AlpacaAPIKey string // Alpaca API key for US stocks
AlpacaSecretKey string // Alpaca secret key
TwelveDataKey string // TwelveData API key for forex & metals
}
// Init initializes global configuration (from .env)
func Init() {
cfg := &Config{
APIServerPort: 8080,
RegistrationEnabled: true,
MaxUsers: 10, // Default: 10 users allowed
ExperienceImprovement: true, // Default: enabled to help improve the product
// Database defaults
DBType: "sqlite",
@@ -71,16 +68,6 @@ func Init() {
cfg.JWTSecret = "default-jwt-secret-change-in-production"
}
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
}
if v := os.Getenv("MAX_USERS"); v != "" {
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
cfg.MaxUsers = maxUsers
}
}
if v := os.Getenv("API_SERVER_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil && port > 0 {
cfg.APIServerPort = port

View File

@@ -97,6 +97,14 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
client = mcp.NewGrokClient()
case "kimi":
client = mcp.NewKimiClient()
case "minimax":
client = mcp.NewMiniMaxClient()
case "blockrun-base":
client = mcp.NewBlockRunBaseClient()
case "blockrun-sol":
client = mcp.NewBlockRunSolClient()
case "claw402":
client = mcp.NewClaw402Client()
default:
client = mcp.New()
}

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)
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
---

View File

@@ -44,6 +44,19 @@ Use custom AI models or third-party OpenAI-compatible APIs:
---
### 💳 BlockRun Wallet (Pay-per-Request, No API Key)
Access all top AI models by paying with USDC — no API key signup required.
| Provider | Guide | Payment Network |
|----------|-------|-----------------|
| BlockRun (Base Wallet) | [blockrun-base-wallet.md](blockrun-base-wallet.md) | Base (EVM) · USDC |
| BlockRun (Solana Wallet) | [blockrun-sol-wallet.md](blockrun-sol-wallet.md) | Solana · USDC |
**How it works:** Each AI request automatically pays a micro-USDC fee via the [x402 payment protocol](https://blockrun.ai). Your private key signs the payment authorization — no funds leave your wallet until the AI response is delivered.
---
## 🔑 Prerequisites
Before starting, ensure you have:

View File

@@ -0,0 +1,126 @@
# BlockRun Base (EVM) Wallet Setup Guide
This guide explains how to use a Base network EVM wallet to pay for AI usage through BlockRun — no API key required.
**Language:** [English](blockrun-base-wallet.md) | [中文](blockrun-base-wallet.zh-CN.md)
## What is BlockRun?
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
NOFX integrates BlockRun via the **x402 micropayment protocol**: each AI inference request automatically pays a small USDC fee directly from your wallet. You only pay for what you use.
## Why Use BlockRun?
| Feature | Traditional API Key | BlockRun Wallet |
|---------|-------------------|-----------------|
| Setup | Register + billing | Just a wallet address |
| Cost model | Monthly subscription | Pay-per-request |
| Models | One provider | All top models |
| Privacy | Account required | Pseudonymous |
| Control | Rate limits apply | Your wallet, your budget |
## Prerequisites
- An EVM wallet with USDC on **Base network** (chain ID 8453)
- The wallet private key (hex format: `0x...`)
### Getting USDC on Base
1. Buy USDC on Coinbase and withdraw to Base, **or**
2. Bridge USDC from Ethereum using [bridge.base.org](https://bridge.base.org), **or**
3. Swap on [Aerodrome](https://aerodrome.finance) or [Uniswap](https://app.uniswap.org) on Base
> **Tip:** A few dollars of USDC is enough to start — each AI call costs fractions of a cent.
## Step 1: Get Your Wallet Private Key
> ⚠️ **Security Warning:** Never share your private key with anyone. Use a dedicated trading wallet, not your main holdings wallet.
**Option A — Create a new wallet (recommended):**
1. Open MetaMask → Create New Account
2. Go to Account Details → Export Private Key
3. Copy the hex key (starts with `0x`)
**Option B — Use an existing wallet:**
1. MetaMask → Account Details → Export Private Key
2. Enter your MetaMask password to reveal the key
**Option C — Generate via CLI:**
```bash
# Using cast (foundry)
cast wallet new
# Output: Address: 0x... | Private key: 0x...
```
## Step 2: Fund the Wallet with USDC on Base
Send USDC to your wallet address on Base network:
- **USDC contract:** `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`
- **Network:** Base (chain ID 8453)
- **Recommended starting amount:** $5$20 USDC
Check your balance at [basescan.org](https://basescan.org).
## Step 3: Configure in NOFX
1. Open NOFX at `http://localhost:3000`
2. Log in and go to **Config** tab
3. Click **+ Add AI Model**
4. In Step 0, scroll to **Via BlockRun Wallet** section
5. Select **BlockRun · Base Wallet**
6. In Step 1, configure:
- **Wallet Private Key:** Your hex private key (`0x...`)
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
7. Click **Save**
## How Payment Works
When NOFX sends an AI request:
1. Request goes to `https://blockrun.ai/api/v1/chat/completions`
2. Server responds with HTTP `402 Payment Required` + payment details
3. NOFX signs a **ERC-3009 TransferWithAuthorization** (EIP-712) with your private key
4. Payment signature is attached and request is retried
5. BlockRun verifies the signature, routes the request to the AI model, and charges USDC
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
## Available Models via BlockRun
| Model ID | Provider | Use Case |
|----------|----------|----------|
| `gpt-5.4` | OpenAI | Flagship (default) |
| `claude-opus-4.6` | Anthropic | Flagship |
| `gemini-3.1-pro` | Google | Flagship |
| `grok-3` | xAI | Flagship |
| `deepseek-chat` | DeepSeek | Flagship |
| `minimax-m2.5` | MiniMax | Flagship |
## Security Best Practices
- ✅ Use a **dedicated wallet** with only trading budget, not your main wallet
- ✅ Keep only a small USDC balance (top up as needed)
- ✅ Your private key is encrypted at rest in NOFX's database
- ✅ Signatures are spend-limited — each signature authorizes only the exact amount for one request
- ❌ Never export or share your private key outside of NOFX
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `no private key set` | Check your key was saved correctly; re-enter in Config |
| `payment retry failed` | Ensure you have USDC on **Base** (not Ethereum mainnet) |
| `invalid private key` | Key must be hex format with `0x` prefix, 66 chars total |
| Payment deducted but no response | Check BlockRun status at [blockrun.ai](https://blockrun.ai) |
| Slow responses | Try selecting a specific model instead of "Auto" |
## Monitoring Spend
Check your USDC balance and transaction history at:
- [Basescan](https://basescan.org) — search your wallet address
- [BlockRun dashboard](https://blockrun.ai) — usage history
---
[← Back to Getting Started](README.md)

View File

@@ -0,0 +1,120 @@
# BlockRun Solana Wallet Setup Guide
This guide explains how to use a Solana wallet to pay for AI usage through BlockRun — no API key required.
**Language:** [English](blockrun-sol-wallet.md) | [中文](blockrun-sol-wallet.zh-CN.md)
## What is BlockRun?
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
NOFX integrates BlockRun via the **x402 micropayment protocol** on Solana: each AI inference request automatically pays a small USDC fee directly from your wallet.
## Prerequisites
- A Solana wallet with USDC on **Solana mainnet**
- The wallet private key (base58-encoded, 64 bytes — standard Solana keypair format)
### Getting USDC on Solana
1. Buy SOL on any exchange and withdraw to your Solana wallet, then swap to USDC on [Jupiter](https://jup.ag), **or**
2. Buy USDC directly on an exchange and withdraw to Solana, **or**
3. Bridge from other chains using [Wormhole](https://wormhole.com)
> **Tip:** A few dollars of USDC is plenty to start.
## Step 1: Export Your Solana Private Key
> ⚠️ **Security Warning:** Use a dedicated wallet for NOFX — not your main holdings wallet.
**From Phantom Wallet:**
1. Open Phantom → Settings (gear icon)
2. Security & Privacy → Export Private Key
3. Enter your password
4. Copy the base58 key (looks like: `5J...` — a long string of ~88 characters)
**From Solflare:**
1. Settings → Export Private Key
2. The key is displayed in base58 format
**From CLI (solana-keygen):**
```bash
# View existing keypair
cat ~/.config/solana/id.json
# This is a JSON array — convert to base58 using:
solana-keygen pubkey ~/.config/solana/id.json
```
> **Note:** NOFX accepts the **base58-encoded 64-byte keypair** (as exported by Phantom/Solflare). This is the standard format for Solana private keys.
## Step 2: Fund the Wallet with USDC on Solana
Send USDC to your Solana wallet:
- **USDC SPL token mint:** `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
- **Network:** Solana Mainnet
- **Recommended starting amount:** $5$20 USDC
Check your balance at [solscan.io](https://solscan.io) or in your wallet app.
## Step 3: Configure in NOFX
1. Open NOFX at `http://localhost:3000`
2. Log in and go to **Config** tab
3. Click **+ Add AI Model**
4. In Step 0, scroll to **Via BlockRun Wallet** section
5. Select **BlockRun · Solana Wallet**
6. In Step 1, configure:
- **Wallet Private Key:** Your base58-encoded Solana private key
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
7. Click **Save**
## How Payment Works
When NOFX sends an AI request:
1. Request goes to `https://sol.blockrun.ai/api/v1/chat/completions`
2. Server responds with HTTP `402 Payment Required` + payment details (nonce, recipient, amount)
3. NOFX signs the payment message `blockrun-payment:{nonce}:{recipient}:{amount}` with your **Ed25519** private key
4. Payment signature is attached and request is retried
5. BlockRun verifies the Ed25519 signature on-chain and routes to the AI model
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
## Available Models via BlockRun
| Model ID | Provider | Use Case |
|----------|----------|----------|
| `gpt-5.4` | OpenAI | Flagship (default) |
| `claude-opus-4.6` | Anthropic | Flagship |
| `gemini-3.1-pro` | Google | Flagship |
| `grok-3` | xAI | Flagship |
| `deepseek-chat` | DeepSeek | Flagship |
| `minimax-m2.5` | MiniMax | Flagship |
## Security Best Practices
- ✅ Use a **dedicated trading wallet** with only your AI budget
- ✅ Keep only a small USDC balance (top up as needed)
- ✅ Your private key is AES-256 encrypted at rest in NOFX's database
- ✅ Ed25519 signatures are one-time — each authorizes only one specific payment
- ❌ Never use your main SOL holdings wallet as the NOFX trading wallet
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `unexpected key length` | Ensure you exported the full 64-byte keypair (not just the 32-byte seed) |
| `failed to decode base58` | Key must be base58 encoded (standard Phantom/Solflare export format) |
| `payment retry failed` | Ensure you have USDC on **Solana mainnet** (not devnet) |
| No response from server | Check `sol.blockrun.ai` is reachable from your server |
| Slow responses | Try selecting a specific model instead of "Auto" |
## Monitoring Spend
Check your USDC balance and transaction history at:
- [Solscan](https://solscan.io) — search your wallet address, filter by USDC token
- [BlockRun dashboard](https://blockrun.ai) — usage history
---
[← Back to Getting Started](README.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/)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE)
<p align="center">
<strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong>
</p>
<p align="center">
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
</p>
<p align="center">
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
</p>
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
---
## AI 駆動の暗号通貨取引プラットフォーム
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
### コア機能
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き 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 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
## 開発者コミュニティ
@@ -49,6 +63,8 @@ NOFXを使用するには以下が必要です:
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
### Perp-DEX (分散型永久先物取引所)

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

23
go.mod
View File

@@ -12,7 +12,6 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.4.0
github.com/rs/zerolog v1.34.0
github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.26.0
@@ -22,10 +21,14 @@ require (
)
require (
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/antihax/optional v1.0.0 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/bitly/go-simplejson v0.5.1 // indirect
github.com/bits-and-blooms/bitset v1.24.0 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
@@ -43,7 +46,12 @@ require (
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
github.com/ethereum/go-verkle v0.2.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gagliardetto/binary v0.8.0 // indirect
github.com/gagliardetto/solana-go v1.14.0 // indirect
github.com/gagliardetto/treeout v0.1.4 // indirect
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -60,15 +68,20 @@ require (
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
@@ -80,6 +93,7 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sonirico/vago v0.10.0 // indirect
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
@@ -89,14 +103,21 @@ require (
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
go.elastic.co/apm/v2 v2.7.1 // indirect
go.elastic.co/fastjson v1.5.1 // indirect
go.mongodb.org/mongo-driver v1.12.2 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/ratelimit v0.2.0 // indirect
go.uber.org/zap v1.21.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

113
go.sum
View File

@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
@@ -8,14 +10,21 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
@@ -64,10 +73,20 @@ github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo2
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -95,8 +114,10 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -133,10 +154,18 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -147,6 +176,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -162,6 +193,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
@@ -170,20 +203,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
@@ -201,6 +238,7 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -217,12 +255,15 @@ github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -231,6 +272,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -247,53 +289,120 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=

View File

@@ -1,6 +1,7 @@
package kernel
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -8,6 +9,7 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/security"
"nofx/store"
@@ -130,7 +132,8 @@ type Context struct {
// Decision AI trading decision
type Decision struct {
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
Leverage int `json:"leverage,omitempty"`
@@ -138,6 +141,12 @@ type Decision struct {
StopLoss float64 `json:"stop_loss,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
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
@@ -440,6 +449,7 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_top":
@@ -459,6 +469,65 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "oi_low":
// 持仓减少榜,适合做空
if !coinSource.UseOILow {
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getOILowCoins(coinSource.OILowLimit)
if err != nil {
return nil, err
}
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "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
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 {
symbol = market.Normalize(symbol)
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) {
if limit <= 0 {
limit = 20
limit = 10
}
positions, err := e.nofxosClient.GetOITopPositions()
@@ -576,6 +678,76 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil
}
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 10
}
positions, err := e.nofxosClient.GetOILowPositions()
if err != nil {
return nil, err
}
var candidates []CandidateCoin
for i, pos := range positions {
if i >= limit {
break
}
symbol := market.Normalize(pos.Symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"oi_low"},
})
}
return candidates, nil
}
// 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
// ============================================================================
@@ -1282,15 +1454,56 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if len(sources) > 1 {
return " (AI500+OI_Top dual signal)"
// 多信号源组合
hasAI500 := false
hasOITop := false
hasOILow := false
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 {
switch sources[0] {
case "ai500":
return " (AI500)"
case "oi_top":
return " (OI_Top position growth)"
return " (OI_Top 持仓增加)"
case "oi_low":
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
}
}
return ""
@@ -1760,8 +1973,8 @@ func compactArrayOpen(s string) string {
// ============================================================================
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
for i, decision := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
for i := range decisions {
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
}
}

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
}

10
main.go
View File

@@ -11,6 +11,7 @@ import (
"nofx/manager"
"nofx/mcp"
"nofx/store"
"nofx/telegram"
"os"
"os/signal"
"path/filepath"
@@ -130,12 +131,21 @@ func main() {
// Start API server
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
// Create hot-reload channel for Telegram bot; wire it to the API server
// so that POST /api/telegram can trigger a bot restart when the token changes.
telegramReloadCh := make(chan struct{}, 1)
server.SetTelegramReloadCh(telegramReloadCh)
go func() {
if err := server.Start(); err != nil {
logger.Fatalf("❌ Failed to start API server: %v", err)
}
}()
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
go telegram.Start(cfg, st, telegramReloadCh)
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

View File

@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
// Concurrently fetch data for each trader
for i, t := range traders {
go func(index int, trader *trader.AutoTrader) {
// Set timeout to 3 seconds for single trader
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use channel for timeout control
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
}
case err := <-errorChan:
// 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{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
}
case <-ctx.Done():
// 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{}{
"trader_id": trader.GetID(),
"trader_name": trader.GetName(),
@@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
return result, nil
}
// RemoveTrader removes a trader from memory (does not affect database)
// Used to force reload when updating trader configuration
// If the trader is running, it will be stopped first
@@ -664,11 +663,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin,
ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
}
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.BitgetSecretKey = string(exchangeCfg.SecretKey)
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
case "gate":
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
case "kucoin":
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner
@@ -703,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
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)

View File

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

345
mcp/blockrun_base.go Normal file
View File

@@ -0,0 +1,345 @@
package mcp
import (
"crypto/ecdsa"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"strings"
"time"
"github.com/ethereum/go-ethereum/crypto"
"golang.org/x/crypto/sha3"
)
const (
ProviderBlockRunBase = "blockrun-base"
DefaultBlockRunBaseURL = "https://blockrun.ai"
DefaultBlockRunModel = "gpt-5.4"
BlockRunChatEndpoint = "/api/v1/chat/completions"
BaseUSDCContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
BaseChainID int64 = 8453
BaseNetwork = "eip155:8453"
)
// EIP-712 type hashes for USDC TransferWithAuthorization (ERC-3009)
var (
eip712DomainTypeHash = keccak256String("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
transferWithAuthTypeHash = keccak256String("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
)
func keccak256String(s string) []byte {
h := sha3.NewLegacyKeccak256()
h.Write([]byte(s))
return h.Sum(nil)
}
func keccak256Bytes(data ...[]byte) []byte {
h := sha3.NewLegacyKeccak256()
for _, b := range data {
h.Write(b)
}
return h.Sum(nil)
}
// BlockRunBaseClient implements AIClient using BlockRun's API with x402 v2 EIP-712 payment signing.
type BlockRunBaseClient struct {
*Client
privateKey *ecdsa.PrivateKey
}
// NewBlockRunBaseClient creates a BlockRun Base wallet client (backward compatible).
func NewBlockRunBaseClient() AIClient {
return NewBlockRunBaseClientWithOptions()
}
// NewBlockRunBaseClientWithOptions creates a BlockRun Base wallet client.
func NewBlockRunBaseClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderBlockRunBase),
WithModel(DefaultBlockRunModel),
WithBaseURL(DefaultBlockRunBaseURL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultBlockRunBaseURL + BlockRunChatEndpoint
c := &BlockRunBaseClient{Client: baseClient}
baseClient.hooks = c
return c
}
// SetAPIKey stores the EVM private key (hex, with or without 0x prefix).
// customModel selects the AI model to use (e.g. "claude-sonnet-4.6"); empty means default.
func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customModel string) {
hexKey := strings.TrimPrefix(apiKey, "0x")
privKey, err := crypto.HexToECDSA(hexKey)
if err != nil {
c.logger.Warnf("⚠️ [MCP] BlockRun Base: invalid private key: %v", err)
} else {
c.privateKey = privKey
c.APIKey = apiKey
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
c.logger.Infof("🔧 [MCP] BlockRun Base wallet: %s", addr)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", DefaultBlockRunModel)
}
}
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
}
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
}
// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value.
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
}
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
// Used by both BlockRunBaseClient and Claw402Client.
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
recipient := opt.PayTo
amount := opt.Amount
network := opt.Network
asset := opt.Asset
extra := opt.Extra
maxTimeout := opt.MaxTimeoutSeconds
if maxTimeout == 0 {
maxTimeout = 300
}
resourceURL := ""
resourceDesc := ""
resourceMime := "application/json"
if resource != nil {
resourceURL = resource.URL
resourceDesc = resource.Description
resourceMime = resource.MimeType
}
now := time.Now().Unix()
validAfter := int64(0)
validBefore := now + int64(maxTimeout)
nonceBytes := make([]byte, 32)
if _, err := rand.Read(nonceBytes); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
nonce := "0x" + hex.EncodeToString(nonceBytes)
domainName := "USD Coin"
domainVersion := "2"
if extra != nil {
if v, ok := extra["name"]; ok && v != "" {
domainName = v
}
if v, ok := extra["version"]; ok && v != "" {
domainVersion = v
}
}
domainSeparator, err := buildDomainSeparatorDynamic(domainName, domainVersion, network, asset)
if err != nil {
return "", fmt.Errorf("failed to build domain separator: %w", err)
}
amountBig, err := parseBigInt(amount)
if err != nil {
return "", fmt.Errorf("invalid amount: %w", err)
}
structHash, err := buildTransferWithAuthHashDynamic(senderAddr, recipient, amountBig, validAfter, validBefore, nonce)
if err != nil {
return "", fmt.Errorf("failed to build struct hash: %w", err)
}
digest := make([]byte, 0, 66)
digest = append(digest, 0x19, 0x01)
digest = append(digest, domainSeparator...)
digest = append(digest, structHash...)
hash := keccak256Bytes(digest)
sig, err := crypto.Sign(hash, privateKey)
if err != nil {
return "", fmt.Errorf("failed to sign: %w", err)
}
if sig[64] < 27 {
sig[64] += 27
}
sigHex := "0x" + hex.EncodeToString(sig)
paymentData := map[string]interface{}{
"x402Version": 2,
"resource": map[string]string{
"url": resourceURL,
"description": resourceDesc,
"mimeType": resourceMime,
},
"accepted": map[string]interface{}{
"scheme": "exact",
"network": network,
"amount": amount,
"asset": asset,
"payTo": recipient,
"maxTimeoutSeconds": maxTimeout,
"extra": extra,
},
"payload": map[string]interface{}{
"signature": sigHex,
"authorization": map[string]string{
"from": senderAddr,
"to": recipient,
"value": amount,
"validAfter": fmt.Sprintf("%d", validAfter),
"validBefore": fmt.Sprintf("%d", validBefore),
"nonce": nonce,
},
},
"extensions": map[string]interface{}{},
}
resultJSON, err := json.Marshal(paymentData)
if err != nil {
return "", fmt.Errorf("failed to marshal payment result: %w", err)
}
return base64.StdEncoding.EncodeToString(resultJSON), nil
}
// buildDomainSeparatorDynamic builds the EIP-712 domain separator using runtime values.
func buildDomainSeparatorDynamic(name, version, network, asset string) ([]byte, error) {
// Extract chain ID from network string like "eip155:8453"
chainID := new(big.Int).SetInt64(BaseChainID)
if strings.HasPrefix(network, "eip155:") {
parts := strings.SplitN(network, ":", 2)
if len(parts) == 2 {
if n, ok := new(big.Int).SetString(parts[1], 10); ok {
chainID = n
}
}
}
contractAddr, err := hex.DecodeString(strings.TrimPrefix(asset, "0x"))
if err != nil {
return nil, fmt.Errorf("invalid contract address: %w", err)
}
nameHash := keccak256String(name)
versionHash := keccak256String(version)
encoded := make([]byte, 0, 5*32)
encoded = append(encoded, leftPad32(eip712DomainTypeHash)...)
encoded = append(encoded, leftPad32(nameHash)...)
encoded = append(encoded, leftPad32(versionHash)...)
encoded = append(encoded, leftPad32(chainID.Bytes())...)
addrPadded := make([]byte, 32)
copy(addrPadded[32-len(contractAddr):], contractAddr)
encoded = append(encoded, addrPadded...)
return keccak256Bytes(encoded), nil
}
// buildTransferWithAuthHashDynamic builds the struct hash for TransferWithAuthorization.
func buildTransferWithAuthHashDynamic(from, to string, value *big.Int, validAfter, validBefore int64, nonce string) ([]byte, error) {
fromBytes, err := hexToAddress(from)
if err != nil {
return nil, fmt.Errorf("invalid from address: %w", err)
}
toBytes, err := hexToAddress(to)
if err != nil {
return nil, fmt.Errorf("invalid to address: %w", err)
}
nonceBytes, err := hexToBytes32(nonce)
if err != nil {
return nil, fmt.Errorf("invalid nonce: %w", err)
}
validAfterBig := new(big.Int).SetInt64(validAfter)
validBeforeBig := new(big.Int).SetInt64(validBefore)
encoded := make([]byte, 0, 7*32)
encoded = append(encoded, leftPad32(transferWithAuthTypeHash)...)
encoded = append(encoded, leftPad32(fromBytes)...)
encoded = append(encoded, leftPad32(toBytes)...)
encoded = append(encoded, leftPad32(value.Bytes())...)
encoded = append(encoded, leftPad32(validAfterBig.Bytes())...)
encoded = append(encoded, leftPad32(validBeforeBig.Bytes())...)
encoded = append(encoded, leftPad32(nonceBytes)...)
return keccak256Bytes(encoded), nil
}
func hexToAddress(s string) ([]byte, error) {
s = strings.TrimPrefix(s, "0x")
b, err := hex.DecodeString(s)
if err != nil {
return nil, err
}
if len(b) != 20 {
return nil, fmt.Errorf("address must be 20 bytes, got %d", len(b))
}
return b, nil
}
func hexToBytes32(s string) ([]byte, error) {
s = strings.TrimPrefix(s, "0x")
b, err := hex.DecodeString(s)
if err != nil {
return nil, err
}
if len(b) > 32 {
return nil, fmt.Errorf("nonce too long: %d bytes", len(b))
}
return b, nil
}
func parseBigInt(s string) (*big.Int, error) {
n := new(big.Int)
// Only treat as hex when explicitly prefixed with 0x/0X.
// x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC).
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
if _, ok := n.SetString(s[2:], 16); ok {
return n, nil
}
return nil, fmt.Errorf("cannot parse hex big.Int from %q", s)
}
if _, ok := n.SetString(s, 10); ok {
return n, nil
}
return nil, fmt.Errorf("cannot parse big.Int from %q", s)
}
// leftPad32 pads a byte slice to 32 bytes on the left (ABI encoding).
func leftPad32(b []byte) []byte {
if len(b) >= 32 {
return b[:32]
}
padded := make([]byte, 32)
copy(padded[32-len(b):], b)
return padded
}
// buildUrl returns the full BlockRun endpoint URL.
func (c *BlockRunBaseClient) buildUrl() string {
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
}
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
}

277
mcp/blockrun_sol.go Normal file
View File

@@ -0,0 +1,277 @@
package mcp
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gagliardetto/solana-go"
"github.com/gagliardetto/solana-go/programs/compute-budget"
"github.com/gagliardetto/solana-go/programs/token"
"github.com/gagliardetto/solana-go/rpc"
)
const (
ProviderBlockRunSol = "blockrun-sol"
DefaultBlockRunSolURL = "https://sol.blockrun.ai"
SolanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
SolanaNetwork = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
SolanaMainnetRPC = "https://api.mainnet-beta.solana.com"
// Compute budget defaults (match @x402/svm)
computeUnitLimit = uint32(8000)
computeUnitPrice = uint64(1)
)
// BlockRunSolClient implements AIClient using BlockRun's Solana x402 v2 payment protocol.
type BlockRunSolClient struct {
*Client
keypair solana.PrivateKey
}
// NewBlockRunSolClient creates a BlockRun Solana wallet client (backward compatible).
func NewBlockRunSolClient() AIClient {
return NewBlockRunSolClientWithOptions()
}
// NewBlockRunSolClientWithOptions creates a BlockRun Solana wallet client.
func NewBlockRunSolClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderBlockRunSol),
WithModel(DefaultBlockRunModel),
WithBaseURL(DefaultBlockRunSolURL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultBlockRunSolURL + BlockRunChatEndpoint
c := &BlockRunSolClient{Client: baseClient}
baseClient.hooks = c
return c
}
// SetAPIKey stores the Solana wallet private key (base58-encoded 64-byte keypair).
// customModel selects the AI model; empty means default.
func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customModel string) {
kp, err := solana.PrivateKeyFromBase58(strings.TrimSpace(apiKey))
if err != nil {
c.logger.Warnf("⚠️ [MCP] BlockRun Sol: failed to parse private key: %v", err)
return
}
c.keypair = kp
c.APIKey = apiKey
c.logger.Infof("🔧 [MCP] BlockRun Sol wallet: %s", kp.PublicKey().String())
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", DefaultBlockRunModel)
}
}
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
}
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
}
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
if c.keypair == nil {
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
}
decoded, err := x402DecodeHeader(paymentHeaderB64)
if err != nil {
return "", err
}
var req x402v2PaymentRequired
if err := json.Unmarshal(decoded, &req); err != nil {
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
}
// Find the Solana option
var opt *x402AcceptOption
for i := range req.Accepts {
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
opt = &req.Accepts[i]
break
}
}
if opt == nil {
return "", fmt.Errorf("no Solana payment option in x402 response")
}
recipient := opt.PayTo
amount := opt.Amount
feePayer := ""
if opt.Extra != nil {
feePayer = opt.Extra["feePayer"]
}
if feePayer == "" {
return "", fmt.Errorf("feePayer missing from Solana x402 extra")
}
maxTimeout := opt.MaxTimeoutSeconds
if maxTimeout == 0 {
maxTimeout = 300
}
resourceURL := DefaultBlockRunSolURL + BlockRunChatEndpoint
resourceDesc := ""
resourceMime := "application/json"
if req.Resource != nil {
resourceURL = req.Resource.URL
resourceDesc = req.Resource.Description
resourceMime = req.Resource.MimeType
}
// Build the SPL TransferChecked transaction
txB64, err := c.buildSolanaTransferTx(recipient, feePayer, amount)
if err != nil {
return "", fmt.Errorf("failed to build Solana transfer tx: %w", err)
}
// Build x402 v2 payment payload
paymentData := map[string]interface{}{
"x402Version": 2,
"resource": map[string]string{
"url": resourceURL,
"description": resourceDesc,
"mimeType": resourceMime,
},
"accepted": map[string]interface{}{
"scheme": "exact",
"network": SolanaNetwork,
"amount": amount,
"asset": SolanaUSDCMint,
"payTo": recipient,
"maxTimeoutSeconds": maxTimeout,
"extra": opt.Extra,
},
"payload": map[string]string{
"transaction": txB64,
},
"extensions": map[string]interface{}{},
}
resultJSON, err := json.Marshal(paymentData)
if err != nil {
return "", fmt.Errorf("failed to marshal Solana payment: %w", err)
}
return base64.StdEncoding.EncodeToString(resultJSON), nil
}
// buildSolanaTransferTx builds a partial-signed VersionedTransaction for SPL USDC TransferChecked.
// The fee payer (CDP facilitator) slot is left with a zero signature; only the user signs.
func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr string) (string, error) {
ownerPubkey := c.keypair.PublicKey()
// Parse recipient and feePayer
recipientPK, err := solana.PublicKeyFromBase58(recipient)
if err != nil {
return "", fmt.Errorf("invalid recipient address: %w", err)
}
feePayerPK, err := solana.PublicKeyFromBase58(feePayer)
if err != nil {
return "", fmt.Errorf("invalid feePayer address: %w", err)
}
mintPK := solana.MustPublicKeyFromBase58(SolanaUSDCMint)
// Parse amount
var amountU64 uint64
if _, err := fmt.Sscanf(amountStr, "%d", &amountU64); err != nil {
return "", fmt.Errorf("invalid amount %q: %w", amountStr, err)
}
// Derive ATAs
sourceATA, _, err := solana.FindAssociatedTokenAddress(ownerPubkey, mintPK)
if err != nil {
return "", fmt.Errorf("failed to derive source ATA: %w", err)
}
destATA, _, err := solana.FindAssociatedTokenAddress(recipientPK, mintPK)
if err != nil {
return "", fmt.Errorf("failed to derive dest ATA: %w", err)
}
// Fetch latest blockhash from Solana mainnet
rpcClient := rpc.New(SolanaMainnetRPC)
bhResp, err := rpcClient.GetLatestBlockhash(context.Background(), rpc.CommitmentFinalized)
if err != nil {
return "", fmt.Errorf("failed to fetch blockhash: %w", err)
}
recentBlockhash := bhResp.Value.Blockhash
// Build instructions: ComputeBudgetSetLimit, ComputeBudgetSetPrice, TransferChecked
setLimitIx, err := computebudget.NewSetComputeUnitLimitInstruction(computeUnitLimit).ValidateAndBuild()
if err != nil {
return "", fmt.Errorf("failed to build SetComputeUnitLimit: %w", err)
}
setPriceIx, err := computebudget.NewSetComputeUnitPriceInstruction(computeUnitPrice).ValidateAndBuild()
if err != nil {
return "", fmt.Errorf("failed to build SetComputeUnitPrice: %w", err)
}
transferIx, err := token.NewTransferCheckedInstruction(
amountU64,
6, // USDC decimals
sourceATA,
mintPK,
destATA,
ownerPubkey,
[]solana.PublicKey{},
).ValidateAndBuild()
if err != nil {
return "", fmt.Errorf("failed to build TransferChecked: %w", err)
}
// Build transaction with feePayer as payer (matches Python SDK)
tx, err := solana.NewTransaction(
[]solana.Instruction{setLimitIx, setPriceIx, transferIx},
recentBlockhash,
solana.TransactionPayer(feePayerPK),
)
if err != nil {
return "", fmt.Errorf("failed to build transaction: %w", err)
}
// Partial sign: user signs; fee_payer (CDP) co-signs on server side
// The transaction has 2 signers: [feePayer (index 0), owner (index 1)]
// We sign only our index (owner).
_, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
if key.Equals(ownerPubkey) {
return &c.keypair
}
return nil // feePayer will be signed by BlockRun CDP
})
if err != nil {
return "", fmt.Errorf("failed to sign transaction: %w", err)
}
// Serialize transaction
txBytes, err := tx.MarshalBinary()
if err != nil {
return "", fmt.Errorf("failed to serialize transaction: %w", err)
}
return base64.StdEncoding.EncodeToString(txBytes), nil
}
// buildUrl returns the full BlockRun Solana endpoint URL.
func (c *BlockRunSolClient) buildUrl() string {
return DefaultBlockRunSolURL + BlockRunChatEndpoint
}
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
}

View File

@@ -1,3 +1,19 @@
// Package mcp — ClaudeClient implements the Anthropic Messages API.
//
// Wire-format differences from the OpenAI-compatible base Client:
//
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
// │ Concept │ OpenAI format │ Anthropic format │
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
// │ System prompt │ messages[0] role=system │ top-level "system" field │
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
// │ Max tokens │ max_tokens │ max_tokens (same) │
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
package mcp
import (
@@ -9,78 +25,67 @@ import (
const (
ProviderClaude = "claude"
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
DefaultClaudeModel = "claude-opus-4-5-20251101"
DefaultClaudeModel = "claude-opus-4-6"
)
// ClaudeClient wraps the base Client and overrides the methods that differ
// for the Anthropic Messages API. All other behaviour (retry, timeout,
// logging) is inherited unchanged.
type ClaudeClient struct {
*Client
}
// NewClaudeClient creates Claude client (backward compatible)
// NewClaudeClient creates a ClaudeClient with default settings.
func NewClaudeClient() AIClient {
return NewClaudeClientWithOptions()
}
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create Claude preset options
claudeOpts := []ClientOption{
baseClient := NewClient(append([]ClientOption{
WithProvider(ProviderClaude),
WithModel(DefaultClaudeModel),
WithBaseURL(DefaultClaudeBaseURL),
}
}, opts...)...).(*Client)
// 2. Merge user options (user options have higher priority)
allOpts := append(claudeOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create Claude client
claudeClient := &ClaudeClient{
Client: baseClient,
}
// 5. Set hooks to point to ClaudeClient (implement dynamic dispatch)
baseClient.hooks = claudeClient
return claudeClient
c := &ClaudeClient{Client: baseClient}
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
return c
}
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
// ── Hook overrides ────────────────────────────────────────────────────────────
// SetAPIKey stores credentials and optional custom endpoint / model.
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
}
}
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
reqHeaders.Set("x-api-key", c.APIKey)
reqHeaders.Set("anthropic-version", "2023-06-01")
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
func (c *ClaudeClient) setAuthHeader(h http.Header) {
h.Set("x-api-key", c.APIKey)
h.Set("anthropic-version", "2023-06-01")
}
// buildUrl Claude uses /messages endpoint
// buildUrl targets /messages instead of /chat/completions.
func (c *ClaudeClient) buildUrl() string {
return fmt.Sprintf("%s/messages", c.BaseURL)
}
// buildMCPRequestBody Claude has different request format
// buildMCPRequestBody builds the Anthropic wire format for the simple
// CallWithMessages path (no tool support).
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
requestBody := map[string]any{
return map[string]any{
"model": c.Model,
"max_tokens": c.MaxTokens,
"system": systemPrompt,
@@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
{"role": "user", "content": userPrompt},
},
}
return requestBody
}
// parseMCPResponse Claude has different response format
// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages
// API wire format. This is the key override that makes tool calling work
// correctly with Claude.
//
// Conversions applied:
//
// - System messages are lifted to the top-level "system" field.
// - Tool definitions: parameters → input_schema, wrapper removed.
// - Assistant messages with ToolCalls → content[{type:tool_use,...}].
// - Tool result messages (role=tool) → role=user with tool_result blocks.
// Consecutive tool results are merged into a single user turn (Anthropic
// requires strictly alternating user/assistant turns).
// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object.
func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any {
// ── 1. Separate system prompt from conversation messages ──────────────────
var systemPrompt string
var convMsgs []Message
for _, m := range req.Messages {
if m.Role == "system" {
systemPrompt = m.Content
} else {
convMsgs = append(convMsgs, m)
}
}
// ── 2. Convert messages to Anthropic format ───────────────────────────────
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
var anthropicTools []map[string]any
for _, t := range req.Tools {
anthropicTools = append(anthropicTools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": t.Function.Parameters,
})
}
// ── 4. Assemble request body ──────────────────────────────────────────────
body := map[string]any{
"model": req.Model,
"max_tokens": c.MaxTokens,
"system": systemPrompt,
"messages": anthropicMsgs,
}
if len(anthropicTools) > 0 {
body["tools"] = anthropicTools
}
// tool_choice: Anthropic uses an object, not a string.
switch req.ToolChoice {
case "auto":
body["tool_choice"] = map[string]any{"type": "auto"}
case "any":
body["tool_choice"] = map[string]any{"type": "any"}
case "none", "":
// omit — no tool_choice sent
}
if req.Temperature != nil {
body["temperature"] = *req.Temperature
}
return body
}
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
// slice to Anthropic's messages array.
//
// Rules:
// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...]
// 2. role=tool (result) → role=user, content=[tool_result, ...]
// Consecutive tool-result messages are merged into one user turn so the
// conversation always alternates user/assistant.
// 3. All other messages → {role, content} as-is.
func convertMessagesToAnthropic(msgs []Message) []map[string]any {
var out []map[string]any
for i := 0; i < len(msgs); {
msg := msgs[i]
switch {
// ── Assistant message carrying tool calls ─────────────────────────────
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
var blocks []map[string]any
for _, tc := range msg.ToolCalls {
// Arguments are a JSON string; Claude wants a parsed object.
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{"_raw": tc.Function.Arguments}
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
out = append(out, map[string]any{
"role": "assistant",
"content": blocks,
})
i++
// ── Tool result message(s) → single user turn ─────────────────────────
case msg.Role == "tool":
// Collect all consecutive tool-result messages.
var blocks []map[string]any
for i < len(msgs) && msgs[i].Role == "tool" {
blocks = append(blocks, map[string]any{
"type": "tool_result",
"tool_use_id": msgs[i].ToolCallID,
"content": msgs[i].Content,
})
i++
}
out = append(out, map[string]any{
"role": "user",
"content": blocks,
})
// ── Regular user / assistant text message ─────────────────────────────
default:
out = append(out, map[string]any{
"role": msg.Role,
"content": msg.Content,
})
i++
}
}
return out
}
// ── Response parsers ──────────────────────────────────────────────────────────
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
// Used by CallWithMessages / CallWithRequest (no tool support).
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
var response struct {
r, err := c.parseMCPResponseFull(body)
if err != nil {
return "", err
}
return r.Content, nil
}
// parseMCPResponseFull extracts both text and tool calls from an Anthropic
// response envelope.
//
// Anthropic response shape:
//
// {
// "content": [
// {"type": "text", "text": "..."},
// {"type": "tool_use", "id": "...", "name": "...", "input": {...}}
// ],
// "stop_reason": "tool_use" | "end_turn"
// }
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
var raw struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
@@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
} `json:"error"`
}
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("failed to parse Anthropic response: %w body: %s", err, body)
}
if raw.Error != nil {
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
}
if response.Error != nil {
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
}
if len(response.Content) == 0 {
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
}
// Report token usage if callback is set
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
if TokenUsageCallback != nil && totalTokens > 0 {
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
if TokenUsageCallback != nil && total > 0 {
TokenUsageCallback(TokenUsage{
Provider: c.Provider,
Model: c.Model,
PromptTokens: response.Usage.InputTokens,
CompletionTokens: response.Usage.OutputTokens,
TotalTokens: totalTokens,
PromptTokens: raw.Usage.InputTokens,
CompletionTokens: raw.Usage.OutputTokens,
TotalTokens: total,
})
}
// Find text content
for _, content := range response.Content {
if content.Type == "text" {
return content.Text, nil
result := &LLMResponse{}
for _, block := range raw.Content {
switch block.Type {
case "text":
result.Content = block.Text
case "tool_use":
// Input is a JSON object; serialise back to a JSON string so it
// matches the ToolCallFunction.Arguments field (always a string).
argsJSON, err := json.Marshal(block.Input)
if err != nil {
argsJSON = []byte("{}")
}
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: block.ID,
Type: "function",
Function: ToolCallFunction{
Name: block.Name,
Arguments: string(argsJSON),
},
})
}
}
return "", fmt.Errorf("no text content in Claude response")
return result, nil
}

248
mcp/claude_client_test.go Normal file
View File

@@ -0,0 +1,248 @@
package mcp
import (
"encoding/json"
"net/http"
"testing"
)
// ── buildRequestBodyFromRequest ────────────────────────────────────────────────
func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{
{Role: "system", Content: "You are helpful."},
{Role: "user", Content: "Hello"},
},
}
body := c.buildRequestBodyFromRequest(req)
if body["system"] != "You are helpful." {
t.Errorf("system not lifted to top level: %v", body["system"])
}
msgs := body["messages"].([]map[string]any)
if len(msgs) != 1 || msgs[0]["role"] != "user" {
t.Errorf("system message should be removed from messages array: %v", msgs)
}
}
func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{{Role: "user", Content: "hi"}},
Tools: []Tool{{
Type: "function",
Function: FunctionDef{
Name: "my_tool",
Description: "does stuff",
Parameters: map[string]any{"type": "object"},
},
}},
}
body := c.buildRequestBodyFromRequest(req)
tools, ok := body["tools"].([]map[string]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools not set correctly: %v", body["tools"])
}
tool := tools[0]
if tool["name"] != "my_tool" {
t.Errorf("tool name wrong: %v", tool["name"])
}
if tool["input_schema"] == nil {
t.Error("tool must use input_schema, not parameters")
}
if _, hasParams := tool["parameters"]; hasParams {
t.Error("tool must NOT have parameters key (Anthropic uses input_schema)")
}
}
func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) {
c := newTestClaudeClient()
req := &Request{
Model: "claude-opus-4-6",
Messages: []Message{{Role: "user", Content: "hi"}},
ToolChoice: "auto",
}
body := c.buildRequestBodyFromRequest(req)
tc, ok := body["tool_choice"].(map[string]any)
if !ok {
t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"])
}
if tc["type"] != "auto" {
t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"])
}
}
// ── convertMessagesToAnthropic ─────────────────────────────────────────────────
func TestConvertMessages_AssistantToolCall(t *testing.T) {
msgs := []Message{
{
Role: "assistant",
ToolCalls: []ToolCall{{
ID: "tc1",
Type: "function",
Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`},
}},
},
}
out := convertMessagesToAnthropic(msgs)
if len(out) != 1 {
t.Fatalf("expected 1 message, got %d", len(out))
}
msg := out[0]
if msg["role"] != "assistant" {
t.Errorf("role should be assistant: %v", msg["role"])
}
blocks := msg["content"].([]map[string]any)
if len(blocks) != 1 || blocks[0]["type"] != "tool_use" {
t.Errorf("content should be tool_use block: %v", blocks)
}
if blocks[0]["id"] != "tc1" {
t.Errorf("tool_use id wrong: %v", blocks[0]["id"])
}
// Input must be parsed JSON object, not a string.
input, ok := blocks[0]["input"].(map[string]any)
if !ok {
t.Errorf("tool_use input must be map, got %T", blocks[0]["input"])
}
if input["method"] != "GET" {
t.Errorf("input.method wrong: %v", input)
}
}
func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) {
// Anthropic requires strictly alternating turns; consecutive tool results
// must be merged into a single user message.
msgs := []Message{
{Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`},
{Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`},
}
out := convertMessagesToAnthropic(msgs)
if len(out) != 1 {
t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out))
}
if out[0]["role"] != "user" {
t.Errorf("tool results must become role=user: %v", out[0]["role"])
}
blocks := out[0]["content"].([]map[string]any)
if len(blocks) != 2 {
t.Errorf("expected 2 tool_result blocks, got %d", len(blocks))
}
if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" {
t.Errorf("blocks should be tool_result: %v", blocks)
}
if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" {
t.Errorf("tool_use_id mismatch: %v", blocks)
}
}
// ── parseMCPResponseFull ───────────────────────────────────────────────────────
func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{
"content": [{"type":"text","text":"Hello from Claude"}],
"usage": {"input_tokens": 10, "output_tokens": 5}
}`)
resp, err := c.parseMCPResponseFull(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Content != "Hello from Claude" {
t.Errorf("content mismatch: %q", resp.Content)
}
if len(resp.ToolCalls) != 0 {
t.Errorf("expected no tool calls: %v", resp.ToolCalls)
}
}
func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{
"content": [{
"type": "tool_use",
"id": "toolu_01abc",
"name": "api_request",
"input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}}
}],
"usage": {"input_tokens": 100, "output_tokens": 30}
}`)
resp, err := c.parseMCPResponseFull(body)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
}
tc := resp.ToolCalls[0]
if tc.ID != "toolu_01abc" {
t.Errorf("tool call ID wrong: %v", tc.ID)
}
if tc.Function.Name != "api_request" {
t.Errorf("function name wrong: %v", tc.Function.Name)
}
// Arguments must be a valid JSON string.
var args map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err)
}
if args["method"] != "POST" {
t.Errorf("args.method wrong: %v", args)
}
}
func TestClaudeClient_ParseResponse_APIError(t *testing.T) {
c := newTestClaudeClient()
body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`)
_, err := c.parseMCPResponseFull(body)
if err == nil {
t.Fatal("expected error for API error response")
}
if err.Error() == "" {
t.Error("error message should not be empty")
}
}
// ── Auth header ────────────────────────────────────────────────────────────────
func TestClaudeClient_SetAuthHeader(t *testing.T) {
c := newTestClaudeClient()
c.APIKey = "sk-ant-test123"
// net/http.Header canonicalizes keys (x-api-key → X-Api-Key).
h := make(http.Header)
c.setAuthHeader(h)
if got := h.Get("x-api-key"); got != "sk-ant-test123" {
t.Errorf("x-api-key header not set correctly: %q", got)
}
if h.Get("anthropic-version") == "" {
t.Error("anthropic-version header must be set")
}
// Must NOT use Authorization: Bearer (that's OpenAI format).
if h.Get("Authorization") != "" {
t.Error("Claude must use x-api-key, not Authorization header")
}
}
func TestClaudeClient_BuildUrl(t *testing.T) {
c := newTestClaudeClient()
url := c.buildUrl()
if url != DefaultClaudeBaseURL+"/messages" {
t.Errorf("URL should be /messages endpoint, got: %s", url)
}
}
// ── helpers ────────────────────────────────────────────────────────────────────
func newTestClaudeClient() *ClaudeClient {
return NewClaudeClientWithOptions().(*ClaudeClient)
}

166
mcp/claw402.go Normal file
View File

@@ -0,0 +1,166 @@
package mcp
import (
"crypto/ecdsa"
"net/http"
"strings"
"github.com/ethereum/go-ethereum/crypto"
)
const (
ProviderClaw402 = "claw402"
DefaultClaw402URL = "https://claw402.ai"
DefaultClaw402Model = "deepseek"
)
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
var claw402ModelEndpoints = map[string]string{
// OpenAI
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
// Anthropic
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
// DeepSeek
"deepseek": "/api/v1/ai/deepseek/chat",
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
// Qwen
"qwen-max": "/api/v1/ai/qwen/chat/max",
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
// Grok
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
// Gemini
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
// Kimi
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
}
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
// Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract).
// When the selected model routes to an Anthropic endpoint, it automatically uses
// the Anthropic wire format for requests and responses (via an internal ClaudeClient).
type Claw402Client struct {
*Client
privateKey *ecdsa.PrivateKey
claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/
}
// NewClaw402Client creates a claw402 client (backward compatible).
func NewClaw402Client() AIClient {
return NewClaw402ClientWithOptions()
}
// NewClaw402ClientWithOptions creates a claw402 client with options.
func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient {
baseOpts := []ClientOption{
WithProvider(ProviderClaw402),
WithModel(DefaultClaw402Model),
WithBaseURL(DefaultClaw402URL),
}
allOpts := append(baseOpts, opts...)
baseClient := NewClient(allOpts...).(*Client)
baseClient.UseFullURL = true
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
c := &Claw402Client{Client: baseClient}
baseClient.hooks = c
return c
}
// SetAPIKey stores the EVM private key and selects the model endpoint.
func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
hexKey := strings.TrimPrefix(apiKey, "0x")
privKey, err := crypto.HexToECDSA(hexKey)
if err != nil {
c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
} else {
c.privateKey = privKey
c.APIKey = apiKey
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
}
if customModel != "" {
c.Model = customModel
}
endpoint := c.resolveEndpoint()
c.BaseURL = DefaultClaw402URL + endpoint
// Anthropic endpoints need different wire format (Messages API)
if strings.Contains(endpoint, "/anthropic/") {
c.claudeProxy = &ClaudeClient{Client: c.Client}
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
} else {
c.claudeProxy = nil
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
}
}
// resolveEndpoint returns the API path for the configured model.
func (c *Claw402Client) resolveEndpoint() string {
if ep, ok := claw402ModelEndpoints[c.Model]; ok {
return ep
}
// Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4")
if strings.HasPrefix(c.Model, "/api/") {
return c.Model
}
return claw402ModelEndpoints[DefaultClaw402Model]
}
func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
}
func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
return x402CallFull(c.Client, c.signPayment, "Claw402", req)
}
// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase).
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
}
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt)
}
return c.Client.buildMCPRequestBody(systemPrompt, userPrompt)
}
func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any {
if c.claudeProxy != nil {
return c.claudeProxy.buildRequestBodyFromRequest(req)
}
return c.Client.buildRequestBodyFromRequest(req)
}
func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) {
if c.claudeProxy != nil {
return c.claudeProxy.parseMCPResponse(body)
}
return c.Client.parseMCPResponse(body)
}
func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
if c.claudeProxy != nil {
return c.claudeProxy.parseMCPResponseFull(body)
}
return c.Client.parseMCPResponseFull(body)
}
// buildUrl returns the full claw402 endpoint URL.
func (c *Claw402Client) buildUrl() string {
return c.BaseURL
}
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
return x402BuildRequest(url, jsonData)
}

View File

@@ -1,7 +1,9 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
}
func (client *Client) parseMCPResponse(body []byte) (string, error) {
r, err := client.parseMCPResponseFull(body)
if err != nil {
return "", err
}
return r.Content, nil
}
// parseMCPResponseFull parses the OpenAI-format response body and returns both
// the text content and any tool calls.
func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
Usage struct {
@@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("API returned empty response")
return nil, fmt.Errorf("API returned empty response")
}
// Report token usage if callback is set
@@ -264,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
})
}
return result.Choices[0].Message.Content, nil
msg := result.Choices[0].Message
return &LLMResponse{
Content: msg.Content,
ToolCalls: msg.ToolCalls,
}, nil
}
func (client *Client) buildUrl() string {
@@ -425,50 +442,106 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
}
// CallWithRequestFull calls the AI API and returns both text content and tool calls.
func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
if client.APIKey == "" {
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
}
if req.Model == "" {
req.Model = client.Model
}
var lastErr error
maxRetries := client.config.MaxRetries
for attempt := 1; attempt <= maxRetries; attempt++ {
if attempt > 1 {
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
}
result, err := client.callWithRequestFull(req)
if err == nil {
return result, nil
}
lastErr = err
if !client.hooks.isRetryableError(err) {
return nil, err
}
if attempt < maxRetries {
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
time.Sleep(waitTime)
}
}
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
}
// callWithRequestFull single call that returns LLMResponse (content + tool calls).
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
requestBody := client.hooks.buildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
if err != nil {
return nil, err
}
url := client.hooks.buildUrl()
httpReq, err := client.hooks.buildRequest(url, jsonData)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, 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("API returned error (status %d): %s", resp.StatusCode, string(body))
}
return client.hooks.parseMCPResponseFull(body)
}
// callWithRequest single AI API call (using Request object)
func (client *Client) callWithRequest(req *Request) (string, error) {
// Print current AI configuration
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
// Build request body (from Request object)
requestBody := client.buildRequestBodyFromRequest(req)
requestBody := client.hooks.buildRequestBodyFromRequest(req)
// Serialize request body
jsonData, err := client.hooks.marshalRequestBody(requestBody)
if err != nil {
return "", err
}
// Build URL
url := client.hooks.buildUrl()
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
// Create HTTP request
httpReq, err := client.hooks.buildRequest(url, jsonData)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Send HTTP request
resp, err := client.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
// Check HTTP status code
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
}
// Parse response
result, err := client.hooks.parseMCPResponse(body)
if err != nil {
return "", fmt.Errorf("fail to parse AI server response: %w", err)
@@ -479,13 +552,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
// buildRequestBodyFromRequest builds request body from Request object
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
// Convert Message to API format
messages := make([]map[string]string, 0, len(req.Messages))
// Convert Message to API format — must use map[string]any to support
// tool-call messages (tool_calls, tool_call_id fields).
messages := make([]map[string]any, 0, len(req.Messages))
for _, msg := range req.Messages {
messages = append(messages, map[string]string{
"role": msg.Role,
"content": msg.Content,
})
m := map[string]any{"role": msg.Role}
if len(msg.ToolCalls) > 0 {
// Assistant message that contains tool invocations.
// content must be null/omitted for OpenAI compatibility.
m["tool_calls"] = msg.ToolCalls
} else if msg.ToolCallID != "" {
// Tool result message (role="tool").
m["tool_call_id"] = msg.ToolCallID
m["content"] = msg.Content
} else {
m["content"] = msg.Content
}
messages = append(messages, m)
}
// Build basic request body
@@ -544,3 +627,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
return requestBody
}
// CallWithRequestStream streams the LLM response via SSE (Server-Sent Events).
// onChunk is called with the full accumulated text so far after each received chunk.
// Returns the complete final text when the stream ends.
//
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
if client.APIKey == "" {
return "", fmt.Errorf("AI API key not set")
}
if req.Model == "" {
req.Model = client.Model
}
req.Stream = true
requestBody := client.hooks.buildRequestBodyFromRequest(req)
jsonData, err := client.hooks.marshalRequestBody(requestBody)
if err != nil {
return "", err
}
url := client.hooks.buildUrl()
httpReq, err := client.hooks.buildRequest(url, jsonData)
if err != nil {
return "", err
}
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
const idleTimeout = 60 * time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
resetCh := make(chan struct{}, 1)
go func() {
t := time.NewTimer(idleTimeout)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
cancel() // idle timeout: kill the connection
return
case <-resetCh:
// received a line — reset the idle timer
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(idleTimeout)
}
}
}()
httpReq = httpReq.WithContext(ctx)
resp, err := client.httpClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("streaming request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var accumulated strings.Builder
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
// Ping the watchdog: we received a line, reset the idle timer.
select {
case resetCh <- struct{}{}:
default:
}
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
// Parse the SSE JSON chunk
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue // skip malformed chunks
}
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta.Content
if delta == "" {
continue
}
accumulated.WriteString(delta)
if onChunk != nil {
onChunk(accumulated.String())
}
}
if err := scanner.Err(); err != nil {
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
}
return accumulated.String(), nil
}

View File

@@ -7,6 +7,7 @@ import (
"time"
"nofx/logger"
"nofx/security"
)
// Config client configuration (centralized management of all configurations)
@@ -48,7 +49,7 @@ func DefaultConfig() *Config {
// Default dependencies (use global logger)
Logger: logger.NewMCPLogger(),
HTTPClient: &http.Client{Timeout: DefaultTimeout},
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
}
}

View File

@@ -10,21 +10,52 @@ type AIClient interface {
SetAPIKey(apiKey string, customURL string, customModel string)
SetTimeout(timeout time.Duration)
CallWithMessages(systemPrompt, userPrompt string) (string, error)
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
CallWithRequest(req *Request) (string, error)
// CallWithRequestStream streams the LLM response via SSE.
// onChunk is called with the full accumulated text so far (not raw deltas).
// Returns the complete final text when done.
CallWithRequestStream(req *Request, onChunk func(string)) (string, error)
// CallWithRequestFull returns both text content and tool calls.
// Use this when the request includes Tools — the LLM may respond with
// either a plain text reply (LLMResponse.Content) or tool invocations
// (LLMResponse.ToolCalls), but not both.
CallWithRequestFull(req *Request) (*LLMResponse, error)
}
// clientHooks internal hook interface (for subclass to override specific steps)
// These methods are only used inside the package to implement dynamic dispatch
// clientHooks is the internal dispatch interface used to implement per-provider
// polymorphism without Go's lack of virtual methods.
//
// Each method can be overridden by an embedding struct (e.g. ClaudeClient).
// The base *Client provides OpenAI-compatible defaults; providers with a
// different wire format (Anthropic, Gemini native, etc.) override only what
// differs. All call-path methods in client.go invoke these via c.hooks so
// that the override is always picked up at runtime.
type clientHooks interface {
// Hook methods that can be overridden by subclass
// ── Simple CallWithMessages path ────────────────────────────────────────
call(systemPrompt, userPrompt string) (string, error)
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
// ── Shared request plumbing ─────────────────────────────────────────────
buildUrl() string
buildRequest(url string, jsonData []byte) (*http.Request, error)
setAuthHeader(reqHeaders http.Header)
marshalRequestBody(requestBody map[string]any) ([]byte, error)
// ── Advanced (Request-object) path ──────────────────────────────────────
// buildRequestBodyFromRequest converts a *Request into the provider's
// native wire-format map. Providers that use a different protocol (e.g.
// Anthropic uses "input_schema" for tools, "tool_use" content blocks, and
// a top-level "system" field) override this method.
buildRequestBodyFromRequest(req *Request) map[string]any
// parseMCPResponse extracts the plain-text reply from a non-streaming
// response body.
parseMCPResponse(body []byte) (string, error)
// parseMCPResponseFull extracts both text and tool calls. Providers whose
// response envelope differs from the OpenAI choices[] structure (e.g.
// Anthropic content[] with tool_use blocks) override this method.
parseMCPResponseFull(body []byte) (*LLMResponse, error)
isRetryableError(err error) bool
}

83
mcp/minimax_client.go Normal file
View File

@@ -0,0 +1,83 @@
package mcp
import (
"net/http"
)
const (
ProviderMiniMax = "minimax"
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.5"
)
type MiniMaxClient struct {
*Client
}
// NewMiniMaxClient creates MiniMax client (backward compatible)
func NewMiniMaxClient() AIClient {
return NewMiniMaxClientWithOptions()
}
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
//
// Usage examples:
//
// // Basic usage
// client := mcp.NewMiniMaxClientWithOptions()
//
// // Custom configuration
// client := mcp.NewMiniMaxClientWithOptions(
// mcp.WithAPIKey("sk-xxx"),
// mcp.WithLogger(customLogger),
// mcp.WithTimeout(60*time.Second),
// )
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
// 1. Create MiniMax preset options
minimaxOpts := []ClientOption{
WithProvider(ProviderMiniMax),
WithModel(DefaultMiniMaxModel),
WithBaseURL(DefaultMiniMaxBaseURL),
}
// 2. Merge user options (user options have higher priority)
allOpts := append(minimaxOpts, opts...)
// 3. Create base client
baseClient := NewClient(allOpts...).(*Client)
// 4. Create MiniMax client
minimaxClient := &MiniMaxClient{
Client: baseClient,
}
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
baseClient.hooks = minimaxClient
return minimaxClient
}
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey
if len(apiKey) > 8 {
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
}
if customURL != "" {
c.BaseURL = customURL
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
}
}
// MiniMax uses standard OpenAI-compatible API with Bearer auth
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
c.Client.setAuthHeader(reqHeaders)
}

272
mcp/minimax_client_test.go Normal file
View File

@@ -0,0 +1,272 @@
package mcp
import (
"testing"
"time"
)
// ============================================================
// Test MiniMaxClient Creation and Configuration
// ============================================================
func TestNewMiniMaxClient_Default(t *testing.T) {
client := NewMiniMaxClient()
if client == nil {
t.Fatal("client should not be nil")
}
// Type assertion check
mmClient, ok := client.(*MiniMaxClient)
if !ok {
t.Fatal("client should be *MiniMaxClient")
}
// Verify default values
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
}
if mmClient.Model != DefaultMiniMaxModel {
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
}
if mmClient.logger == nil {
t.Error("logger should not be nil")
}
if mmClient.httpClient == nil {
t.Error("httpClient should not be nil")
}
}
func TestNewMiniMaxClientWithOptions(t *testing.T) {
mockLogger := NewMockLogger()
customModel := "MiniMax-M2.5-highspeed"
customAPIKey := "sk-custom-key"
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
WithModel(customModel),
WithAPIKey(customAPIKey),
WithMaxTokens(4000),
)
mmClient := client.(*MiniMaxClient)
// Verify custom options are applied
if mmClient.logger != mockLogger {
t.Error("logger should be set from option")
}
if mmClient.Model != customModel {
t.Error("Model should be set from option")
}
if mmClient.APIKey != customAPIKey {
t.Error("APIKey should be set from option")
}
if mmClient.MaxTokens != 4000 {
t.Error("MaxTokens should be 4000")
}
// Verify MiniMax default values are retained
if mmClient.Provider != ProviderMiniMax {
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
}
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
}
}
// ============================================================
// Test SetAPIKey
// ============================================================
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
// Test setting API Key (default URL and Model)
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
if mmClient.APIKey != "sk-test-key-12345678" {
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
if len(logs) == 0 {
t.Error("should have logged API key setting")
}
// Verify BaseURL and Model remain default
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
t.Error("BaseURL should remain default")
}
if mmClient.Model != DefaultMiniMaxModel {
t.Error("Model should remain default")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customURL := "https://api.minimaxi.com/v1"
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
if mmClient.BaseURL != customURL {
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomURLLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
hasCustomURLLog = true
break
}
}
if !hasCustomURLLog {
t.Error("should have logged custom BaseURL")
}
}
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithLogger(mockLogger),
)
mmClient := client.(*MiniMaxClient)
customModel := "MiniMax-M2.5-highspeed"
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
if mmClient.Model != customModel {
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
}
// Verify logging
logs := mockLogger.GetLogsByLevel("INFO")
hasCustomModelLog := false
for _, log := range logs {
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
hasCustomModelLog = true
break
}
}
if !hasCustomModelLog {
t.Error("should have logged custom Model")
}
}
// ============================================================
// Test Integration Features
// ============================================================
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
mockHTTP := NewMockHTTPClient()
mockHTTP.SetSuccessResponse("MiniMax AI response")
mockLogger := NewMockLogger()
client := NewMiniMaxClientWithOptions(
WithHTTPClient(mockHTTP.ToHTTPClient()),
WithLogger(mockLogger),
WithAPIKey("sk-test-key"),
)
result, err := client.CallWithMessages("system prompt", "user prompt")
if err != nil {
t.Fatalf("should not error: %v", err)
}
if result != "MiniMax AI response" {
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
}
// Verify request
requests := mockHTTP.GetRequests()
if len(requests) != 1 {
t.Fatalf("expected 1 request, got %d", len(requests))
}
req := requests[0]
// Verify URL
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if req.URL.String() != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
}
// Verify Authorization header
authHeader := req.Header.Get("Authorization")
if authHeader != "Bearer sk-test-key" {
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
}
// Verify Content-Type
if req.Header.Get("Content-Type") != "application/json" {
t.Error("Content-Type should be application/json")
}
}
func TestMiniMaxClient_Timeout(t *testing.T) {
client := NewMiniMaxClientWithOptions(
WithTimeout(30 * time.Second),
)
mmClient := client.(*MiniMaxClient)
if mmClient.httpClient.Timeout != 30*time.Second {
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
}
// Test SetTimeout
client.SetTimeout(60 * time.Second)
if mmClient.httpClient.Timeout != 60*time.Second {
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
}
}
// ============================================================
// Test hooks Mechanism
// ============================================================
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
client := NewMiniMaxClientWithOptions()
mmClient := client.(*MiniMaxClient)
// Verify hooks point to mmClient itself (implements polymorphism)
if mmClient.hooks != mmClient {
t.Error("hooks should point to mmClient for polymorphism")
}
// Verify buildUrl uses MiniMax configuration
url := mmClient.buildUrl()
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
if url != expectedURL {
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
}
}

View File

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

View File

@@ -22,7 +22,11 @@ func WithLogger(logger Logger) ClientOption {
}
}
// WithHTTPClient sets custom HTTP client
// WithHTTPClient sets custom HTTP client.
//
// WARNING: The default client uses security.SafeHTTPClient() with SSRF protection
// (blocks private IPs, cloud metadata, validates redirects). Overriding it bypasses
// these protections. Only use in tests or with a client providing equivalent safeguards.
//
// Usage example:
// httpClient := &http.Client{Timeout: 60 * time.Second}
@@ -160,3 +164,17 @@ func WithQwenConfig(apiKey string) ClientOption {
c.Model = DefaultQwenModel
}
}
// WithMiniMaxConfig sets MiniMax configuration
//
// Usage example:
//
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
func WithMiniMaxConfig(apiKey string) ClientOption {
return func(c *Config) {
c.Provider = ProviderMiniMax
c.APIKey = apiKey
c.BaseURL = DefaultMiniMaxBaseURL
c.Model = DefaultMiniMaxModel
}
}

View File

@@ -1,9 +1,34 @@
package mcp
// Message represents a conversation message
// Message represents a conversation message.
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
// and tool result messages (Role="tool", ToolCallID, Content).
type Message struct {
Role string `json:"role"` // "system", "user", "assistant"
Content string `json:"content"` // Message content
Role string `json:"role"` // "system", "user", "assistant", "tool"
Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools
ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages
}
// ToolCall is a single function call requested by the LLM.
type ToolCall struct {
ID string `json:"id"` // Unique call ID (e.g. "call_abc123")
Type string `json:"type"` // Always "function"
Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments
}
// ToolCallFunction holds the function name and raw JSON arguments string.
type ToolCallFunction struct {
Name string `json:"name"` // Function name
Arguments string `json:"arguments"` // JSON-encoded argument object
}
// LLMResponse is returned by CallWithRequestFull and carries both the assistant
// text reply (Content) and any structured tool calls (ToolCalls).
// Exactly one of the two fields will be non-empty for a well-formed response.
type LLMResponse struct {
Content string // Plain-text reply (final answer)
ToolCalls []ToolCall // Structured tool invocations
}
// Tool represents a tool/function that AI can call

219
mcp/x402.go Normal file
View File

@@ -0,0 +1,219 @@
package mcp
import (
"bytes"
"crypto/ecdsa"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/ethereum/go-ethereum/crypto"
)
// ── Shared x402 types ────────────────────────────────────────────────────────
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
type x402v2PaymentRequired struct {
X402Version int `json:"x402Version"`
Accepts []x402AcceptOption `json:"accepts"`
Resource *x402Resource `json:"resource"`
}
// x402AcceptOption is a payment option from the x402 v2 header.
type x402AcceptOption struct {
Scheme string `json:"scheme"`
Network string `json:"network"`
Amount string `json:"amount"`
Asset string `json:"asset"`
PayTo string `json:"payTo"`
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
Extra map[string]string `json:"extra"`
}
// x402Resource describes the resource being paid for.
type x402Resource struct {
URL string `json:"url"`
Description string `json:"description"`
MimeType string `json:"mimeType"`
}
// x402SignFunc is a callback that signs an x402 payment header and returns the
// base64-encoded payment signature.
type x402SignFunc func(paymentHeaderB64 string) (string, error)
// ── Shared x402 helpers ──────────────────────────────────────────────────────
// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
// trying RawStdEncoding first then StdEncoding as fallback.
func x402DecodeHeader(b64 string) ([]byte, error) {
decoded, err := base64.RawStdEncoding.DecodeString(b64)
if err != nil {
decoded, err = base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("failed to base64-decode payment header: %w", err)
}
}
return decoded, nil
}
// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402.
func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
if privateKey == nil {
return "", fmt.Errorf("no private key set for %s wallet", providerName)
}
decoded, err := x402DecodeHeader(paymentHeaderB64)
if err != nil {
return "", err
}
var req x402v2PaymentRequired
if err := json.Unmarshal(decoded, &req); err != nil {
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
}
if len(req.Accepts) == 0 {
return "", fmt.Errorf("no payment options in x402 response")
}
senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
}
// doX402Request executes an HTTP request and handles the x402 v2 payment flow.
// On a 402 response it reads the Payment-Required (or X-Payment-Required) header,
// signs via signFn, retries with Payment-Signature, and logs the Payment-Response
// header (tx hash) on success.
func doX402Request(
httpClient *http.Client,
buildReqFn func() (*http.Request, error),
signFn x402SignFunc,
providerTag string,
logger Logger,
) ([]byte, error) {
req, err := buildReqFn()
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPaymentRequired {
paymentHeader := resp.Header.Get("Payment-Required")
if paymentHeader == "" {
paymentHeader = resp.Header.Get("X-Payment-Required")
}
if paymentHeader == "" {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body))
}
// Drain 402 body to allow HTTP connection reuse.
_, _ = io.Copy(io.Discard, resp.Body)
paymentSig, err := signFn(paymentHeader)
if err != nil {
return nil, fmt.Errorf("failed to sign x402 payment: %w", err)
}
req2, err := buildReqFn()
if err != nil {
return nil, fmt.Errorf("failed to build retry request: %w", err)
}
req2.Header.Set("X-Payment", paymentSig)
req2.Header.Set("Payment-Signature", paymentSig)
resp2, err := httpClient.Do(req2)
if err != nil {
return nil, fmt.Errorf("failed to send payment retry: %w", err)
}
defer resp2.Body.Close()
body2, err := io.ReadAll(resp2.Body)
if err != nil {
return nil, fmt.Errorf("failed to read payment retry response: %w", err)
}
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2))
}
if txHash := resp2.Header.Get("Payment-Response"); txHash != "" {
logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash)
}
return body2, nil
}
body, 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("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
}
return body, nil
}
// x402BuildRequest creates a POST request with Content-Type but no auth header.
func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("fail to build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
func x402SetAuthHeader(_ http.Header) {}
// x402Call handles the x402 payment flow for the simple CallWithMessages path.
func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
jsonData, err := c.hooks.marshalRequestBody(requestBody)
if err != nil {
return "", err
}
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
}, signFn, tag, c.logger)
if err != nil {
return "", err
}
return c.hooks.parseMCPResponse(body)
}
// x402CallFull handles the x402 payment flow for the advanced Request path.
func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) {
if c.APIKey == "" {
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
}
if req.Model == "" {
req.Model = c.Model
}
c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
requestBody := c.hooks.buildRequestBodyFromRequest(req)
jsonData, err := c.hooks.marshalRequestBody(requestBody)
if err != nil {
return nil, err
}
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
}, signFn, tag, c.logger)
if err != nil {
return nil, err
}
return c.hooks.parseMCPResponseFull(body)
}

View File

@@ -15,11 +15,18 @@ server {
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# index.html — never cache (so new deploys take effect immediately)
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
# Frontend routes (SPA) with static asset caching
location / {
try_files $uri $uri/ /index.html;
# Cache static assets
# Cache hashed static assets (js/css have content hashes in filenames)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";

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

View File

@@ -106,11 +106,11 @@ func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosit
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
data, err := c.GetOIRanking("1h", 20)
positions, _, err := c.fetchOIRanking("top", "1h", 20)
if err != nil {
return nil, err
}
return data.TopPositions, nil
return positions, nil
}
// GetOITopSymbols retrieves OI top coin symbol list
@@ -129,6 +129,31 @@ func (c *Client) GetOITopSymbols() ([]string, error) {
return symbols, nil
}
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
positions, _, err := c.fetchOIRanking("low", "1h", 20)
if err != nil {
return nil, err
}
return positions, nil
}
// GetOILowSymbols retrieves OI low coin symbol list
func (c *Client) GetOILowSymbols() ([]string, error) {
positions, err := c.GetOILowPositions()
if err != nil {
return nil, err
}
var symbols []string
for _, pos := range positions {
symbol := NormalizeSymbol(pos.Symbol)
symbols = append(symbols, symbol)
}
return symbols, nil
}
// FormatOIRankingForAI formats OI ranking data for AI consumption
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
if data == nil {

View File

@@ -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")
}

183
start.sh
View File

@@ -1,7 +1,7 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# NOFX AI Trading System - Docker Quick Start Script
# NOFX AI Trading System - Docker Management Script
# Usage: ./start.sh [command]
# ═══════════════════════════════════════════════════════════════
@@ -45,10 +45,10 @@ detect_compose_cmd() {
elif command -v docker-compose &> /dev/null; then
COMPOSE_CMD="docker-compose"
else
print_error "Docker Compose 未安装!请先安装 Docker Compose"
print_error "Docker Compose not found. Please install Docker Compose first."
exit 1
fi
print_info "使用 Docker Compose 命令: $COMPOSE_CMD"
print_info "Using Docker Compose: $COMPOSE_CMD"
}
# ------------------------------------------------------------------------
@@ -56,12 +56,12 @@ detect_compose_cmd() {
# ------------------------------------------------------------------------
check_docker() {
if ! command -v docker &> /dev/null; then
print_error "Docker 未安装!请先安装 Docker: https://docs.docker.com/get-docker/"
print_error "Docker not found. Please install Docker: https://docs.docker.com/get-docker/"
exit 1
fi
detect_compose_cmd
print_success "Docker Docker Compose 已安装"
print_success "Docker and Docker Compose are installed"
}
# ------------------------------------------------------------------------
@@ -69,11 +69,11 @@ check_docker() {
# ------------------------------------------------------------------------
check_env() {
if [ ! -f ".env" ]; then
print_warning ".env 不存在,从模板复制..."
print_warning ".env not found, copying from template..."
cp .env.example .env
print_info "已创建 .env 文件"
print_info ".env file created"
fi
print_success "环境变量文件存在"
print_success "Environment file exists"
}
# ------------------------------------------------------------------------
@@ -83,15 +83,15 @@ is_env_configured() {
local var_name="$1"
local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-)
# 去除引号
# Strip quotes
value=$(echo "$value" | tr -d '"'"'")
# 检查是否为空或占位符
# Check empty
if [ -z "$value" ]; then
return 1
fi
# 检查是否是示例值
# Check placeholder values
case "$value" in
*your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*)
return 1
@@ -102,22 +102,23 @@ is_env_configured() {
}
# ------------------------------------------------------------------------
# Helper: Generate and set env var in .env file
# Helper: Set env var in .env file
# ------------------------------------------------------------------------
set_env_var() {
local var_name="$1"
local var_value="$2"
# 如果变量已存在(即使是占位符),替换它
if grep -q "^${var_name}=" .env 2>/dev/null; then
# macOS 和 Linux 兼容的 sed
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env
else
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env
fi
else
# 变量不存在,追加
# Ensure .env ends with a newline before appending
if [ -s ".env" ] && [ "$(tail -c1 .env | wc -l)" -eq 0 ]; then
echo "" >> .env
fi
echo "${var_name}=${var_value}" >> .env
fi
}
@@ -126,51 +127,46 @@ set_env_var() {
# Validation: Encryption Keys in .env
# ------------------------------------------------------------------------
check_encryption() {
print_info "检查加密密钥配置..."
print_info "Checking encryption keys..."
local generated=false
# 检查并生成 JWT_SECRET
if ! is_env_configured "JWT_SECRET"; then
print_warning "JWT_SECRET 未配置,正在生成..."
print_warning "JWT_SECRET not set, generating..."
local jwt_secret=$(openssl rand -base64 32)
set_env_var "JWT_SECRET" "$jwt_secret"
print_success "JWT_SECRET 已生成"
print_success "JWT_SECRET generated"
generated=true
fi
# 检查并生成 DATA_ENCRYPTION_KEY
if ! is_env_configured "DATA_ENCRYPTION_KEY"; then
print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..."
print_warning "DATA_ENCRYPTION_KEY not set, generating..."
local data_key=$(openssl rand -base64 32)
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
print_success "DATA_ENCRYPTION_KEY 已生成"
print_success "DATA_ENCRYPTION_KEY generated"
generated=true
fi
# 检查并生成 RSA_PRIVATE_KEY
if ! is_env_configured "RSA_PRIVATE_KEY"; then
print_warning "RSA_PRIVATE_KEY 未配置,正在生成..."
# 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n
print_warning "RSA_PRIVATE_KEY not set, generating..."
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
print_success "RSA_PRIVATE_KEY 已生成"
print_success "RSA_PRIVATE_KEY generated"
generated=true
fi
if [ "$generated" = true ]; then
echo ""
print_success "所有缺失的密钥已自动生成并保存到 .env"
print_warning "请妥善保管 .env 文件,不要提交到版本控制系统"
print_success "Missing keys generated and saved to .env"
print_warning "Keep .env safe — do not commit it to version control"
echo ""
fi
print_success "加密密钥检查完成"
print_success "Encryption keys OK"
print_info " • JWT_SECRET: OK"
print_info " • DATA_ENCRYPTION_KEY: OK"
print_info " • RSA_PRIVATE_KEY: OK"
# 修复 .env 文件权限
chmod 600 .env 2>/dev/null || true
}
@@ -197,13 +193,12 @@ read_env_vars() {
# Validation: Database Directory (data/)
# ------------------------------------------------------------------------
check_database() {
# Ensure data directory exists
if [ ! -d "data" ]; then
print_warning "数据目录不存在,创建 data/ 目录..."
print_warning "Data directory missing, creating data/..."
install -m 700 -d data
print_success "已创建 data/ 目录"
print_success "data/ directory created"
else
print_success "数据目录存在"
print_success "Data directory exists"
fi
}
@@ -211,47 +206,58 @@ check_database() {
# Service Management: Start
# ------------------------------------------------------------------------
start() {
print_info "正在启动 NOFX AI Trading System..."
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ 🚀 NOFX AI Trading Bot — Startup ║${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
read_env_vars
if [ ! -d "data" ]; then
print_info "创建数据目录..."
install -m 700 -d data
fi
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
print_info "Starting services..."
if [ "$1" == "--build" ]; then
print_info "重新构建镜像..."
$COMPOSE_CMD up -d --build
else
print_info "启动容器..."
$COMPOSE_CMD up -d
fi
print_success "服务已启动!"
print_info "Web 界面: http://localhost:${NOFX_FRONTEND_PORT}"
print_info "API 端点: http://localhost:${NOFX_BACKEND_PORT}"
print_info ""
print_info "查看日志: ./start.sh logs"
print_info "停止服务: ./start.sh stop"
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
echo " 1. Open the web dashboard to register and configure"
echo " 2. Add an AI model and exchange in Settings"
echo " 3. (Optional) Add a Telegram bot token in Settings → Telegram"
echo ""
echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC}"
echo -e " View logs: ${YELLOW}./start.sh logs${NC}"
echo -e " Stop: ${YELLOW}./start.sh stop${NC}"
echo ""
}
# ------------------------------------------------------------------------
# Service Management: Stop
# ------------------------------------------------------------------------
stop() {
print_info "正在停止服务..."
print_info "Stopping services..."
$COMPOSE_CMD stop
print_success "服务已停止"
print_success "Services stopped"
}
# ------------------------------------------------------------------------
# Service Management: Restart
# ------------------------------------------------------------------------
restart() {
print_info "正在重启服务..."
print_info "Restarting services..."
$COMPOSE_CMD restart
print_success "服务已重启"
print_success "Services restarted"
}
# ------------------------------------------------------------------------
@@ -271,25 +277,25 @@ logs() {
status() {
read_env_vars
print_info "服务状态:"
print_info "Service status:"
$COMPOSE_CMD ps
echo ""
print_info "健康检查:"
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应"
print_info "Health check:"
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding"
}
# ------------------------------------------------------------------------
# Maintenance: Clean (Destructive)
# ------------------------------------------------------------------------
clean() {
print_warning "这将删除所有容器和数据!"
read -p "确认删除?(yes/no): " confirm
print_warning "This will delete all containers and data!"
read -p "Confirm? (yes/no): " confirm
if [ "$confirm" == "yes" ]; then
print_info "正在清理..."
print_info "Cleaning up..."
$COMPOSE_CMD down -v
print_success "清理完成"
print_success "Cleanup complete"
else
print_info "已取消"
print_info "Cancelled"
fi
}
@@ -297,77 +303,74 @@ clean() {
# Maintenance: Update
# ------------------------------------------------------------------------
update() {
print_info "正在更新..."
print_info "Updating..."
git pull
$COMPOSE_CMD up -d --build
print_success "更新完成"
print_success "Update complete"
}
# ------------------------------------------------------------------------
# Command: Regenerate all keys (force)
# ------------------------------------------------------------------------
regenerate_keys() {
print_warning "这将重新生成所有加密密钥!"
print_warning "如果已有加密数据,重新生成后将无法解密!"
print_warning "This will regenerate ALL encryption keys!"
print_warning "Any existing encrypted data will become unreadable!"
echo ""
read -p "确认重新生成?(yes/no): " confirm
read -p "Confirm? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
print_info "已取消"
print_info "Cancelled"
return
fi
check_env
print_info "正在生成新的密钥..."
print_info "Generating new keys..."
# 生成 JWT_SECRET
local jwt_secret=$(openssl rand -base64 32)
set_env_var "JWT_SECRET" "$jwt_secret"
print_success "JWT_SECRET 已生成"
print_success "JWT_SECRET generated"
# 生成 DATA_ENCRYPTION_KEY
local data_key=$(openssl rand -base64 32)
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
print_success "DATA_ENCRYPTION_KEY 已生成"
print_success "DATA_ENCRYPTION_KEY generated"
# 生成 RSA_PRIVATE_KEY
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
print_success "RSA_PRIVATE_KEY 已生成"
print_success "RSA_PRIVATE_KEY generated"
chmod 600 .env 2>/dev/null || true
echo ""
print_success "所有密钥已重新生成并保存到 .env"
print_warning "请妥善保管 .env 文件"
print_success "All keys regenerated and saved to .env"
print_warning "Keep .env safe"
}
# ------------------------------------------------------------------------
# Help: Usage Information
# ------------------------------------------------------------------------
show_help() {
echo "NOFX AI Trading System - Docker 管理脚本"
echo "NOFX AI Trading System - Docker Management Script"
echo ""
echo "用法: ./start.sh [command] [options]"
echo "Usage: ./start.sh [command] [options]"
echo ""
echo "命令:"
echo " start [--build] 启动服务(可选:重新构建)"
echo " stop 停止服务"
echo " restart 重启服务"
echo " logs [service] 查看日志(可选:指定服务名 backend/frontend"
echo " status 查看服务状态"
echo " clean 清理所有容器和数据"
echo " update 更新代码并重启"
echo " regenerate-keys 重新生成所有加密密钥(慎用)"
echo " help 显示此帮助信息"
echo "Commands:"
echo " start [--build] Start services (optional: rebuild images)"
echo " stop Stop services"
echo " restart Restart services"
echo " logs [service] View logs (optional: backend / frontend)"
echo " status Show service status"
echo " clean Remove all containers and data"
echo " update Pull latest code and rebuild"
echo " regenerate-keys Regenerate all encryption keys (destructive)"
echo " help Show this help"
echo ""
echo "示例:"
echo " ./start.sh start --build # 构建并启动"
echo " ./start.sh logs backend # 查看后端日志"
echo " ./start.sh status # 查看状态"
echo "Examples:"
echo " ./start.sh start --build # Build and start"
echo " ./start.sh logs backend # View backend logs"
echo " ./start.sh status # Check status"
echo ""
echo "首次使用:"
echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成"
echo "First time:"
echo " Just run ./start.sh — missing keys are generated automatically"
}
# ------------------------------------------------------------------------
@@ -408,7 +411,7 @@ main() {
show_help
;;
*)
print_error "未知命令: $1"
print_error "Unknown command: $1"
show_help
exit 1
;;

View File

@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
return &model, nil
}
// GetAnyEnabled returns the first enabled AI model across all users.
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
var model AIModel
err := s.db.Where("enabled = ? AND api_key != ''", true).
Order("updated_at DESC, id ASC").
First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
// Update updates AI model, creates if not exists
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {

View File

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

View File

@@ -17,27 +17,28 @@ type ExchangeStore struct {
// Exchange exchange configuration
type Exchange struct {
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
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"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
ID string `gorm:"primaryKey" json:"id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
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"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
Enabled bool `gorm:"default:false" json:"enabled"`
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
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"`
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"`
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Exchange) TableName() string { return "exchanges" }
@@ -173,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
return "Aster DEX", "dex"
case "lighter":
return "LIGHTER DEX", "dex"
case "indodax":
return "Indodax", "cex"
default:
return exchangeType + " Exchange", "cex"
}
@@ -181,7 +184,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled 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) {
id := uuid.New().String()
@@ -207,6 +211,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser,
AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
@@ -224,19 +229,21 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID
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)
updates := map[string]interface{}{
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
"enabled": enabled,
"testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_user": asterUser,
"aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now().UTC(),
}
// Only update encrypted fields if not empty
@@ -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)
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,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
hyperliquidWalletAddr, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return 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

@@ -3,12 +3,63 @@ package store
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"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
type TraderStats struct {
TotalTrades int `json:"total_trades"`
@@ -156,18 +207,22 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
newEntryPrice = math.Round(newEntryPrice*100) / 100
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"quantity": newQty,
"entry_quantity": newEntryQty,
"entry_price": newEntryPrice,
"fee": newFee,
"updated_at": nowMs,
}).Error
}
// ReducePositionQuantity reduces position quantity for partial close
// If quantity reaches 0 (or near 0), automatically closes the position
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
var pos TraderPosition
if err := s.db.First(&pos, id).Error; err != nil {
@@ -184,7 +239,26 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64
if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
newExitPrice = math.Round(newExitPrice*100) / 100
// Use 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{}{
@@ -192,14 +266,17 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
"fee": newFee,
"exit_price": newExitPrice,
"realized_pnl": newPnL,
"updated_at": nowMs,
}).Error
}
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exchange_id": exchangeID,
"exchange_type": exchangeType,
"updated_at": nowMs,
}).Error
}

View File

@@ -147,7 +147,8 @@ func (pb *PositionBuilder) handleClose(
var finalExitPrice float64
if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
finalExitPrice = math.Round(finalExitPrice*100) / 100
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
} else {
finalExitPrice = price
}

View File

@@ -18,16 +18,18 @@ type Store struct {
driver *DBDriver // Database driver for abstraction (legacy)
// Sub-stores (lazy initialization)
user *UserStore
aiModel *AIModelStore
exchange *ExchangeStore
trader *TraderStore
decision *DecisionStore
backtest *BacktestStore
position *PositionStore
strategy *StrategyStore
equity *EquityStore
order *OrderStore
user *UserStore
aiModel *AIModelStore
exchange *ExchangeStore
trader *TraderStore
decision *DecisionStore
backtest *BacktestStore
position *PositionStore
strategy *StrategyStore
equity *EquityStore
order *OrderStore
grid *GridStore
telegramConfig TelegramConfigStore
mu sync.RWMutex
}
@@ -156,6 +158,12 @@ func (s *Store) initTables() error {
if err := s.Order().InitTables(); err != nil {
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)
}
if err := s.TelegramConfig().(*telegramConfigStore).initTables(); err != nil {
return fmt.Errorf("failed to initialize telegram config tables: %w", err)
}
return nil
}
@@ -279,6 +287,26 @@ func (s *Store) Order() *OrderStore {
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
}
// TelegramConfig gets Telegram bot configuration storage
func (s *Store) TelegramConfig() TelegramConfigStore {
s.mu.Lock()
defer s.mu.Unlock()
if s.telegramConfig == nil {
s.telegramConfig = NewTelegramConfigStore(s.gdb)
}
return s.telegramConfig
}
// Close closes database connection
func (s *Store) Close() error {
if s.driver != nil {

View File

@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
// StrategyConfig strategy configuration details (JSON structure)
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
// This determines the language used for data formatting and prompt generation
Language string `json:"language,omitempty"`
@@ -45,6 +48,43 @@ type StrategyConfig struct {
RiskControl RiskControlConfig `json:"risk_control"`
// editable sections of System Prompt
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
@@ -61,7 +101,7 @@ type PromptSectionsConfig struct {
// CoinSourceConfig coin source configuration
type CoinSourceConfig struct {
// source type: "static" | "ai500" | "oi_top" | "mixed"
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
SourceType string `json:"source_type"`
// static coin list (used when source_type = "static")
StaticCoins []string `json:"static_coins,omitempty"`
@@ -71,10 +111,20 @@ type CoinSourceConfig struct {
UseAI500 bool `json:"use_ai500"`
// AI500 coin pool maximum count
AI500Limit int `json:"ai500_limit,omitempty"`
// whether to use OI Top
// whether to use OI Top (持仓增加榜,适合做多)
UseOITop bool `json:"use_oi_top"`
// OI Top maximum count
OITopLimit int `json:"oi_top_limit,omitempty"`
// whether to use OI Low (持仓减少榜,适合做空)
UseOILow bool `json:"use_oi_low"`
// OI Low maximum count
OILowLimit int `json:"oi_low_limit,omitempty"`
// 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
}
@@ -212,7 +262,9 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
UseAI500: true,
AI500Limit: 10,
UseOITop: false,
OITopLimit: 20,
OITopLimit: 10,
UseOILow: false,
OILowLimit: 10,
},
Indicators: IndicatorConfig{
Klines: KlineConfig{

164
store/telegram_config.go Normal file
View File

@@ -0,0 +1,164 @@
package store
import (
"errors"
"fmt"
"sync"
"time"
"gorm.io/gorm"
)
// TelegramConfig stores the Telegram bot binding (single row, always ID=1)
type TelegramConfig struct {
ID uint `gorm:"primaryKey"`
BotToken string `gorm:"column:bot_token"`
ChatID int64 `gorm:"column:chat_id"`
Username string `gorm:"column:username"` // @username for display
BoundAt time.Time `gorm:"column:bound_at"`
ModelID string `gorm:"column:model_id;default:''"` // AI model used for Telegram replies
Language string `gorm:"column:language;default:''"` // "zh" or "en"; empty = not chosen yet
CreatedAt time.Time
UpdatedAt time.Time
}
// String returns a safe string representation of TelegramConfig with the token masked.
func (tc TelegramConfig) String() string {
token := "***"
if tc.BotToken == "" {
token = "<not set>"
}
return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}",
tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt)
}
// TelegramConfigStore defines the interface for Telegram bot binding operations
type TelegramConfigStore interface {
Get() (*TelegramConfig, error) // Get current config (may not exist)
SaveToken(botToken string) error // Save bot token only (Web UI sets this)
Save(botToken, modelID string) error // Save bot token + selected AI model
BindUser(chatID int64, username string) error // Called on first /start
IsBound() (bool, error) // Check if any user is bound
GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound)
Unbind() error // Remove binding
SetLanguage(lang string) error // Set UI language ("en" or "zh")
GetLanguage() string // Get UI language; returns "en" if not set
}
type telegramConfigStore struct {
db *gorm.DB
mu sync.RWMutex
}
// NewTelegramConfigStore creates a new TelegramConfigStore
func NewTelegramConfigStore(db *gorm.DB) TelegramConfigStore {
return &telegramConfigStore{db: db}
}
func (s *telegramConfigStore) initTables() error {
return s.db.AutoMigrate(&TelegramConfig{})
}
func (s *telegramConfigStore) Get() (*TelegramConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var cfg TelegramConfig
if err := s.db.First(&cfg, 1).Error; err != nil {
return nil, err
}
return &cfg, nil
}
func (s *telegramConfigStore) SaveToken(botToken string) error {
return s.Save(botToken, "")
}
func (s *telegramConfigStore) Save(botToken, modelID string) error {
s.mu.Lock()
defer s.mu.Unlock()
var cfg TelegramConfig
result := s.db.First(&cfg, 1)
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return result.Error
}
cfg.ID = 1
cfg.BotToken = botToken
cfg.ModelID = modelID
return s.db.Save(&cfg).Error
}
func (s *telegramConfigStore) BindUser(chatID int64, username string) error {
s.mu.Lock()
defer s.mu.Unlock()
var cfg TelegramConfig
result := s.db.First(&cfg, 1)
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return result.Error
}
cfg.ID = 1
cfg.ChatID = chatID
cfg.Username = username
cfg.BoundAt = time.Now()
return s.db.Save(&cfg).Error
}
func (s *telegramConfigStore) IsBound() (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var cfg TelegramConfig
if err := s.db.First(&cfg, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return cfg.ChatID != 0, nil
}
func (s *telegramConfigStore) GetBoundChatID() (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var cfg TelegramConfig
if err := s.db.First(&cfg, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, err
}
return cfg.ChatID, nil
}
func (s *telegramConfigStore) Unbind() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.db.Model(&TelegramConfig{}).Where("id = 1").Updates(map[string]interface{}{
"chat_id": 0,
"username": "",
}).Error
}
func (s *telegramConfigStore) SetLanguage(lang string) error {
s.mu.Lock()
defer s.mu.Unlock()
var cfg TelegramConfig
result := s.db.First(&cfg, 1)
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
return result.Error
}
cfg.ID = 1
cfg.Language = lang
return s.db.Save(&cfg).Error
}
func (s *telegramConfigStore) GetLanguage() string {
s.mu.RLock()
defer s.mu.RUnlock()
var cfg TelegramConfig
if err := s.db.First(&cfg, 1).Error; err != nil {
return "en" // default: English
}
if cfg.Language == "" {
return "en"
}
return cfg.Language
}

View File

@@ -248,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
}
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
import (
"crypto/rand"
"encoding/base32"
"time"
"gorm.io/gorm"
@@ -18,24 +16,12 @@ type User struct {
ID string `gorm:"primaryKey" json:"id"`
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
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"`
UpdatedAt time.Time `json:"updated_at"`
}
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
func NewUserStore(db *gorm.DB) *UserStore {
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 created_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)
var indexExists int64
@@ -114,9 +97,11 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
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
// GetAll returns all users ordered by creation time.
func (s *UserStore) GetAll() ([]User, error) {
var users []User
err := s.db.Model(&User{}).Order("created_at").Find(&users).Error
return users, err
}
// UpdatePassword updates password
@@ -138,7 +123,5 @@ func (s *UserStore) EnsureAdmin() error {
ID: "admin",
Email: "admin@localhost",
PasswordHash: "",
OTPSecret: "",
OTPVerified: true,
})
}

285
telegram/agent/agent.go Normal file
View File

@@ -0,0 +1,285 @@
package agent
import (
"encoding/json"
"fmt"
"nofx/auth"
"nofx/logger"
"nofx/mcp"
"nofx/telegram/session"
"strings"
)
const maxIterations = 10
// apiRequestTool is the single tool exposed to the LLM.
// Native function calling means the LLM returns EITHER ToolCalls OR Content — never both.
// This makes narration structurally impossible: text cannot appear alongside a tool call.
var apiRequestTool = mcp.Tool{
Type: "function",
Function: mcp.FunctionDef{
Name: "api_request",
Description: "Call the NOFX trading system REST API",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"method": map[string]any{
"type": "string",
"enum": []string{"GET", "POST", "PUT", "DELETE"},
"description": "HTTP method",
},
"path": map[string]any{
"type": "string",
"description": "API path; include query params in path: /api/positions?trader_id=xxx",
},
"body": map[string]any{
"type": "object",
"description": "Request body; use {} for GET requests",
},
},
"required": []string{"method", "path", "body"},
},
},
}
// Agent is a stateful AI agent for one Telegram chat.
// It exposes a single "api_request" tool and runs a loop until the LLM
// returns a plain-text reply (no tool calls).
type Agent struct {
apiTool *apiCallTool
getLLM func() mcp.AIClient
memory *session.Memory
systemPrompt string
userID string
}
// New creates an Agent for one chat session.
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
return &Agent{
apiTool: newAPICallTool(apiPort, botToken),
getLLM: getLLM,
memory: session.NewMemory(getLLM()),
systemPrompt: systemPrompt,
userID: userID,
}
}
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
// userID must match the actual registered user's ID so bot-made changes
// are visible in the frontend (shared user namespace).
func GenerateBotToken(userID string) (string, error) {
return auth.GenerateJWT(userID, "bot@internal")
}
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
// and per-trader account summary + statistics) and returns it as a formatted string for
// injection into the LLM context at the start of each conversation.
func (a *Agent) buildAccountContext() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[Current Account State — User: %s]\n\n", a.userID))
// ── AI Models ─────────────────────────────────────────────────────────────
modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"})
sb.WriteString("## AI Models\n")
sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n")
sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n")
var models []struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 {
for _, m := range models {
status := "disabled"
if m.Enabled {
status = "ENABLED"
}
sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status))
}
} else {
sb.WriteString(modelsRaw)
}
sb.WriteString("\n")
// ── Exchanges ─────────────────────────────────────────────────────────────
exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"})
sb.WriteString("## Exchanges\n")
sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n")
var exchanges []struct {
ID string `json:"id"`
Name string `json:"name"`
ExchangeType string `json:"exchange_type"`
AccountName string `json:"account_name"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 {
for _, e := range exchanges {
status := "disabled"
if e.Enabled {
status = "ENABLED"
}
sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status))
}
} else {
sb.WriteString(exchangesRaw)
}
sb.WriteString("\n")
// ── Strategies ────────────────────────────────────────────────────────────
strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"})
sb.WriteString("## Strategies\n")
var strategies []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 {
for _, s := range strategies {
sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name))
}
} else {
sb.WriteString(strategiesRaw)
}
sb.WriteString("\n")
// ── Traders ───────────────────────────────────────────────────────────────
tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"})
sb.WriteString("## Traders\n")
var traders []struct {
TraderID string `json:"trader_id"`
Name string `json:"trader_name"`
IsRunning bool `json:"is_running"`
}
if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 {
for _, t := range traders {
status := "stopped"
if t.IsRunning {
status = "RUNNING"
}
sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status))
}
} else {
sb.WriteString(tradersRaw)
}
sb.WriteString("\n")
// ── Per-trader live data (running traders only) ────────────────────────────
for _, t := range traders {
if !t.IsRunning {
continue
}
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Account [%s]:\n%s\n\n", t.Name, acct))
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats))
}
return sb.String()
}
// Run processes one user message through the native function-calling agent loop.
//
// Architecture:
// - LLM receives the api_request tool definition alongside conversation history.
// - LLM response is EITHER ToolCalls (execute API) OR Content (final reply) — never both.
// This is enforced by the protocol: narration is structurally impossible.
// - Loop continues until the LLM returns a plain-text reply (no tool calls).
//
// On the first message of a conversation the live account state is fetched and injected.
// onChunk is optional; when set it is called once with the complete final reply text.
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
llm := a.getLLM()
if llm == nil {
return "AI assistant unavailable. Please configure an AI model in the Web UI."
}
// Build initial user message: prepend account state on first turn, history on subsequent turns.
histCtx := a.memory.BuildContext()
var firstUserContent string
if histCtx == "" {
accountCtx := a.buildAccountContext()
firstUserContent = accountCtx + "\n[User Message]\n" + userMessage
} else {
firstUserContent = histCtx + "\n---\nUser: " + userMessage
}
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstUserContent)}
for i := 0; i < maxIterations; i++ {
req, err := mcp.NewRequestBuilder().
WithSystemPrompt(a.systemPrompt).
AddConversationHistory(turnMsgs).
AddTool(apiRequestTool).
WithToolChoice("auto").
Build()
if err != nil {
logger.Errorf("Agent: failed to build request: %v", err)
break
}
resp, err := llm.CallWithRequestFull(req)
if err != nil {
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
return "AI assistant temporarily unavailable. Please try again."
}
// No tool calls → LLM returned a final text reply.
if len(resp.ToolCalls) == 0 {
reply := strings.TrimSpace(resp.Content)
if onChunk != nil {
onChunk(reply)
}
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// Tool call iteration — show thinking indicator.
if onChunk != nil {
onChunk("⏳")
}
// Append assistant message carrying the tool calls (no content field).
turnMsgs = append(turnMsgs, mcp.Message{
Role: "assistant",
ToolCalls: resp.ToolCalls,
})
// Execute each tool call and append the results as tool messages.
for _, tc := range resp.ToolCalls {
var apiReq apiRequest
if err := json.Unmarshal([]byte(tc.Function.Arguments), &apiReq); err != nil {
logger.Errorf("Agent: invalid tool args for call %s: %v", tc.ID, err)
turnMsgs = append(turnMsgs, mcp.Message{
Role: "tool",
ToolCallID: tc.ID,
Content: fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err.Error()),
})
continue
}
logger.Infof("Agent: iter=%d tool=%s %s %s", i+1, tc.ID, apiReq.Method, apiReq.Path)
result := a.apiTool.execute(&apiReq)
turnMsgs = append(turnMsgs, mcp.Message{
Role: "tool",
ToolCallID: tc.ID,
Content: result,
})
}
}
// Safety: max iterations reached.
logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage)
reply := "操作已完成,请检查您的账户查看最新状态。"
a.memory.Add("user", userMessage)
a.memory.Add("assistant", reply)
return reply
}
// ResetMemory clears conversation history (called on /start).
func (a *Agent) ResetMemory() {
a.memory.ResetFull()
}

View File

@@ -0,0 +1,439 @@
package agent
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"nofx/mcp"
)
// mockLLM implements mcp.AIClient using pre-programmed LLMResponse objects.
// Native function calling: CallWithRequestFull is the primary method;
// CallWithRequest and CallWithRequestStream are stubs kept for interface compliance.
type mockLLM struct {
responses []*mcp.LLMResponse
calls int
lastMsgs []mcp.Message
}
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
func (m *mockLLM) SetTimeout(_ time.Duration) {}
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return "", nil }
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
r, err := m.next()
if err != nil {
return "", err
}
return r.Content, nil
}
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
r, err := m.next()
if err != nil {
return "", err
}
if onChunk != nil {
onChunk(r.Content)
}
return r.Content, nil
}
func (m *mockLLM) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
m.lastMsgs = req.Messages
return m.next()
}
func (m *mockLLM) next() (*mcp.LLMResponse, error) {
if m.calls < len(m.responses) {
r := m.responses[m.calls]
m.calls++
return r, nil
}
return &mcp.LLMResponse{Content: "OK"}, nil
}
// toolCall builds a mock LLM response that contains a single tool invocation.
func toolCall(id, method, path string, body string) *mcp.LLMResponse {
if body == "" {
body = "{}"
}
return &mcp.LLMResponse{
ToolCalls: []mcp.ToolCall{{
ID: id,
Type: "function",
Function: mcp.ToolCallFunction{
Name: "api_request",
Arguments: fmt.Sprintf(`{"method":%q,"path":%q,"body":%s}`, method, path, body),
},
}},
}
}
// textReply builds a mock LLM response with a plain-text final answer.
func textReply(content string) *mcp.LLMResponse {
return &mcp.LLMResponse{Content: content}
}
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
return func() mcp.AIClient { return llm }
}
const testPrompt = "You are a test assistant."
// mockAPIServer creates a test HTTP server with configurable route handlers.
func mockAPIServer(handlers map[string]string) (*httptest.Server, int) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + r.URL.Path
if body, ok := handlers[key]; ok {
w.Write([]byte(body)) //nolint:errcheck
return
}
// Also try path-only match (for GET)
if body, ok := handlers[r.URL.Path]; ok {
w.Write([]byte(body)) //nolint:errcheck
return
}
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"not found"}`)) //nolint:errcheck
}))
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
return srv, port
}
// ── Basic agent behaviour ──────────────────────────────────────────────────
// TestAgentDirectReply: LLM replies with text (no tool calls) — one LLM call.
func TestAgentDirectReply(t *testing.T) {
llm := &mockLLM{responses: []*mcp.LLMResponse{textReply("Hello! How can I help you?")}}
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("hello", nil)
if reply != "Hello! How can I help you?" {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 1 {
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
}
}
// TestAgentAPICall: LLM makes one tool call, gets result, gives final reply — two LLM calls.
func TestAgentAPICall(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/my-traders": `[{"trader_id":"t1","trader_name":"BTC Trader","is_running":false}]`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "GET", "/api/my-traders", "{}"),
textReply("You have one trader: BTC Trader."),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("list my traders", nil)
if reply != "You have one trader: BTC Trader." {
t.Fatalf("unexpected reply: %q", reply)
}
if llm.calls != 2 {
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
}
}
// TestAgentMultiStep: LLM chains two tool calls before final reply — three LLM calls.
func TestAgentMultiStep(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/account": `{"total_equity":1000}`,
"/api/positions": `[]`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "GET", "/api/account", "{}"),
toolCall("c2", "GET", "/api/positions", "{}"),
textReply("Account looks healthy and no open positions."),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("show me account status", nil)
if llm.calls != 3 {
t.Fatalf("expected 3 LLM calls (2 tool + 1 final), got %d", llm.calls)
}
if reply != "Account looks healthy and no open positions." {
t.Fatalf("unexpected final reply: %q", reply)
}
}
// TestAgentAPIResultInContext: tool result must appear as a tool message in the next LLM call.
func TestAgentAPIResultInContext(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/account": `{"balance":1234.56}`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "GET", "/api/account", "{}"),
textReply("Balance is 1234.56 USDT."),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
a.Run("show balance", nil)
// The last request must contain a tool-result message with the balance data.
found := false
for _, msg := range llm.lastMsgs {
if msg.Role == "tool" && strings.Contains(msg.Content, "balance") {
found = true
break
}
}
if !found {
t.Fatalf("tool result message not found in subsequent LLM context; messages: %+v", llm.lastMsgs)
}
}
// ── Narration-free architecture tests ─────────────────────────────────────
// TestNarrationStructurallyImpossible: when ToolCalls are present in the response,
// any Content field is ignored and never surfaced to the user.
// In real LLM APIs, Content is always empty alongside ToolCalls, but we verify
// our agent handles a malformed response defensively.
func TestNarrationStructurallyImpossible(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/strategies": `[{"id":"s1","name":"BTC Trend"}]`,
})
defer srv.Close()
// Simulate a (malformed) response that has both Content and ToolCalls.
malformed := &mcp.LLMResponse{
Content: "现在我将为您查询策略。", // narration — must NOT reach user
ToolCalls: []mcp.ToolCall{{
ID: "c1",
Type: "function",
Function: mcp.ToolCallFunction{
Name: "api_request",
Arguments: `{"method":"GET","path":"/api/strategies","body":{}}`,
},
}},
}
llm := &mockLLM{responses: []*mcp.LLMResponse{
malformed,
textReply("你有1个策略BTC Trend。"),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("查询我的策略", nil)
if strings.Contains(reply, "现在我将") {
t.Fatalf("narration leaked into final reply: %q", reply)
}
if reply != "你有1个策略BTC Trend。" {
t.Fatalf("unexpected reply: %q", reply)
}
}
// TestOnChunkCalledWithFinalReply: onChunk receives the complete final reply.
func TestOnChunkCalledWithFinalReply(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/account": `{"equity":500}`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "GET", "/api/account", "{}"),
textReply("Equity: 500 USDT."),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
var chunks []string
reply := a.Run("show equity", func(chunk string) {
chunks = append(chunks, chunk)
})
if reply != "Equity: 500 USDT." {
t.Fatalf("unexpected reply: %q", reply)
}
// Should have received ⏳ for the tool call, then the final reply.
if len(chunks) < 2 {
t.Fatalf("expected at least 2 chunks (⏳ + final), got: %v", chunks)
}
lastChunk := chunks[len(chunks)-1]
if lastChunk != "Equity: 500 USDT." {
t.Fatalf("last chunk should be final reply, got: %q", lastChunk)
}
}
// ── Workflow tests ─────────────────────────────────────────────────────────
// TestCreateStrategyWorkflow: simulates creating a BTC trend strategy.
// Verifies: POST strategy → GET verify → final reply shows strategy info.
func TestCreateStrategyWorkflow(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`,
"GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`),
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
textReply("策略已创建BTC趋势币种 BTC/USDT杠杆 5x。"),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("帮我配置个btc趋势交易的策略", nil)
if llm.calls != 3 {
t.Fatalf("expected 3 LLM calls, got %d", llm.calls)
}
if reply == "" {
t.Fatalf("empty final reply")
}
}
// TestFullSetupWorkflow: create strategy → verify → create trader → start trader.
// This is the "帮我配置策略并跑起来" workflow.
func TestFullSetupWorkflow(t *testing.T) {
calls := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + r.URL.Path
calls[key]++
switch key {
case "POST /api/strategies":
w.Write([]byte(`{"id":"s1","name":"BTC趋势"}`)) //nolint:errcheck
case "GET /api/strategies/s1":
w.Write([]byte(`{"id":"s1","name":"BTC趋势","config":{}}`)) //nolint:errcheck
case "POST /api/traders":
w.Write([]byte(`{"id":"tr1","name":"BTC趋势交易员"}`)) //nolint:errcheck
case "POST /api/traders/tr1/start":
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`),
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`),
toolCall("c4", "POST", "/api/traders/tr1/start", "{}"),
textReply("策略和交易员已创建并启动BTC趋势交易员正在运行。"),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil)
if llm.calls != 5 {
t.Fatalf("expected 5 LLM calls, got %d", llm.calls)
}
if calls["POST /api/strategies"] != 1 {
t.Errorf("expected 1 POST /api/strategies, got %d", calls["POST /api/strategies"])
}
if calls["POST /api/traders"] != 1 {
t.Errorf("expected 1 POST /api/traders, got %d", calls["POST /api/traders"])
}
if calls["POST /api/traders/tr1/start"] != 1 {
t.Errorf("expected 1 POST /api/traders/tr1/start, got %d", calls["POST /api/traders/tr1/start"])
}
if reply == "" {
t.Fatalf("empty final reply")
}
}
// TestStartExistingTrader: when trader already exists, just start it.
func TestStartExistingTrader(t *testing.T) {
calls := map[string]int{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + r.URL.Path
calls[key]++
switch key {
case "GET /api/my-traders":
w.Write([]byte(`[{"trader_id":"tr1","trader_name":"BTC Trader","is_running":false}]`)) //nolint:errcheck
case "POST /api/traders/tr1/start":
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
var port int
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("c1", "GET", "/api/my-traders", "{}"),
toolCall("c2", "POST", "/api/traders/tr1/start", "{}"),
textReply("交易员 BTC Trader 已启动。"),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("启动交易员", nil)
if calls["POST /api/traders/tr1/start"] != 1 {
t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"])
}
if reply != "交易员 BTC Trader 已启动。" {
t.Fatalf("unexpected reply: %q", reply)
}
}
// ── Safety limit ───────────────────────────────────────────────────────────
// TestMaxIterations: agent terminates after maxIterations and returns fallback message.
func TestMaxIterations(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/account": `{"ok":true}`,
})
defer srv.Close()
// Always returns another tool call — should hit max iterations.
responses := make([]*mcp.LLMResponse, maxIterations+2)
for i := range responses {
responses[i] = toolCall(fmt.Sprintf("c%d", i), "GET", "/api/account", "{}")
}
llm := &mockLLM{responses: responses}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
reply := a.Run("loop forever", nil)
if reply == "" {
t.Fatalf("expected a fallback reply, got empty string")
}
// Agent should have made exactly maxIterations tool-call LLM calls.
if llm.calls != maxIterations {
t.Fatalf("expected %d LLM calls (max iterations), got %d", maxIterations, llm.calls)
}
}
// TestToolCallIDPropagated: tool result messages carry the correct ToolCallID.
func TestToolCallIDPropagated(t *testing.T) {
srv, port := mockAPIServer(map[string]string{
"/api/account": `{"balance":999}`,
})
defer srv.Close()
llm := &mockLLM{responses: []*mcp.LLMResponse{
toolCall("call-xyz-123", "GET", "/api/account", "{}"),
textReply("Balance is 999."),
}}
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
a.Run("check balance", nil)
// Find the tool result message and verify ToolCallID matches.
found := false
for _, msg := range llm.lastMsgs {
if msg.Role == "tool" && msg.ToolCallID == "call-xyz-123" {
found = true
break
}
}
if !found {
t.Fatalf("tool result with ToolCallID='call-xyz-123' not found in messages: %+v", llm.lastMsgs)
}
}

88
telegram/agent/apicall.go Normal file
View File

@@ -0,0 +1,88 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"nofx/logger"
"strings"
"time"
)
// apiCallTool executes HTTP requests against the NOFX API server.
// This is the only tool available to the agent.
type apiCallTool struct {
baseURL string
token string
client *http.Client
}
// apiRequest holds the arguments decoded from the LLM's api_request tool call.
type apiRequest struct {
Method string `json:"method"`
Path string `json:"path"`
Body map[string]any `json:"body"`
}
func newAPICallTool(port int, token string) *apiCallTool {
return &apiCallTool{
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// execute calls the API and returns the response as a string for LLM consumption.
func (t *apiCallTool) execute(req *apiRequest) string {
if req.Method == "" || req.Path == "" {
return "error: method and path are required"
}
if !strings.HasPrefix(req.Path, "/") {
req.Path = "/" + req.Path
}
var bodyReader io.Reader
if req.Method != "GET" && len(req.Body) > 0 {
b, err := json.Marshal(req.Body)
if err != nil {
return fmt.Sprintf("error marshaling body: %v", err)
}
bodyReader = bytes.NewReader(b)
}
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
if err != nil {
return fmt.Sprintf("error creating request: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+t.token)
resp, err := t.client.Do(httpReq)
if err != nil {
return fmt.Sprintf("API call failed: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Sprintf("error reading response: %v", err)
}
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
if resp.StatusCode >= 400 {
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
}
// Pretty-print JSON for better LLM readability
var v any
if json.Unmarshal(body, &v) == nil {
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
return string(pretty)
}
}
return string(body)
}

79
telegram/agent/manager.go Normal file
View File

@@ -0,0 +1,79 @@
package agent
import (
"nofx/logger"
"nofx/mcp"
"sync"
"time"
)
// Manager holds one Agent per Telegram chat ID.
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
type Manager struct {
mu sync.Mutex
agents map[int64]*Agent
lanes map[int64]chan struct{}
apiPort int
botToken string
userID string
getLLM func() mcp.AIClient
systemPrompt string
}
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
// userEmail is the registered email shown to the user when they ask "who am I".
// userID is the internal DB UUID used for API authentication.
func NewManager(apiPort int, botToken, userEmail, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
return &Manager{
agents: make(map[int64]*Agent),
lanes: make(map[int64]chan struct{}),
apiPort: apiPort,
botToken: botToken,
userID: userID,
getLLM: getLLM,
systemPrompt: BuildAgentPrompt(apiDocs, userEmail, userID),
}
}
// Run processes a message for the given chat ID.
// If the same chat is already processing a message, this call blocks until it completes
// or the lane wait times out (60 s), whichever comes first.
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
a, lane := m.getOrCreate(chatID)
select {
case lane <- struct{}{}:
case <-time.After(60 * time.Second):
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
return "上一条消息仍在处理中,请稍等片刻后再试。"
}
defer func() { <-lane }()
return a.Run(userMessage, onChunk)
}
// Reset clears memory for the given chat (called on /start).
func (m *Manager) Reset(chatID int64) {
m.mu.Lock()
a, ok := m.agents[chatID]
m.mu.Unlock()
if ok {
a.ResetMemory()
}
}
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.agents[chatID]
if !ok {
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
m.agents[chatID] = a
}
lane, ok := m.lanes[chatID]
if !ok {
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
m.lanes[chatID] = lane
}
return a, lane
}

97
telegram/agent/prompt.go Normal file
View File

@@ -0,0 +1,97 @@
package agent
import "fmt"
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
// userEmail is the registered email of the bound user (shown when user asks "who am I").
// userID is the internal DB UUID used for API authentication only.
func BuildAgentPrompt(apiDocs, userEmail, userID string) string {
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
## Your Identity
- You are operating as: %s
- Internal user ID (for API calls only): %s
- When asked "which user / account / email" — answer with the email address above
- All API calls are made on behalf of this user
## Tool: api_request
Use the api_request tool to call the NOFX REST API:
- method: "GET" | "POST" | "PUT" | "DELETE"
- path: API path; query params go in the path: /api/positions?trader_id=xxx
- body: JSON object (use {} for GET requests)
## NOFX API Documentation
%s
## CRITICAL: Exact ID Rule (read this before every API call)
API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value
from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute.
Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id
Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models
The Account State block at the start of this conversation lists every resource with its exact id.
Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess.
## Behavior Rules
1. Reply in the same language the user used (中文→中文, English→English)
2. Keep final replies concise — show results, not process
3. Ask for ALL missing required info in ONE message — never ask one field at a time
4. When user provides enough info, act immediately — no confirmation needed
5. Be decisive — infer intent from context, use schema to fill in smart defaults
## Verification Rule (CRITICAL)
After ANY PUT or POST that creates or modifies a resource:
1. Immediately GET the resource to read actual saved values
2. Show the user the KEY fields they care about from the GET response
3. NEVER just say "updated successfully" without showing the actual values
4. If saved values look wrong, correct them automatically
## Error Handling
- 400: explain what was wrong, ask user to correct
- 404: resource doesn't exist — you may have used the wrong ID format; check the Account State for the exact id
- "AI model not enabled": tell user to enable the model first via PUT /api/models
- "Exchange not enabled": tell user to enable the exchange first
- 5xx: server error, ask user to try again
## Account State (injected at conversation start)
At the start of each new conversation, a [Current Account State] block is provided with:
- AI Models: all configured models with their IDs and enabled status
- Exchanges: all configured exchanges with their IDs and enabled status
- Strategies: all existing strategies with their IDs
- Traders: all existing traders with their IDs and running status
Use this to:
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
- Know instantly if the user has 0 or N resources of each type
- If only one exchange/model exists and user doesn't specify, use it directly without asking
- If multiple exist, list them and ask which one to use
## Common Workflows
**Create strategy** (independent from traders):
- Never GET trader info just to create a strategy.
- POST {"name":"<descriptive name>"} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable.
- Only include "config" when user explicitly requests custom settings (specific coins, custom leverage, different timeframes).
- After POST: GET /api/strategies/:id to verify → show user: name, coin_source.source_type, key risk_control values
**"帮我配置策略并跑起来" / "create strategy and start" (full setup workflow)**:
Execute these steps IN ORDER with NO user confirmation between them:
1. POST /api/strategies — body: {"name":"<descriptive name>"} — no config needed, defaults are complete
2. GET /api/strategies/:id — verify strategy was saved
3. POST /api/traders — create trader: use exchange_id and model_id from Account State (if only one each, use directly); set strategy_id from step 1; set name matching the strategy
4. POST /api/traders/:id/start — start the trader
5. Final reply: show strategy name, trader name, coin source, confirm running
**Update strategy config**:
1. GET /api/strategies/:id to read current full config
2. Modify only what user asked (keep all other fields)
3. PUT /api/strategies/:id with complete merged config
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
**Start/stop existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask.
**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs)
}

479
telegram/bot.go Normal file
View File

@@ -0,0 +1,479 @@
package telegram
import (
"nofx/api"
"nofx/config"
"nofx/logger"
"nofx/mcp"
"nofx/store"
"nofx/telegram/agent"
"os"
"strings"
"sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
for {
token := resolveToken(cfg, st)
if token == "" {
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
<-reloadCh
continue
}
stopped := runBot(token, cfg, st)
if !stopped {
return
}
select {
case <-reloadCh:
logger.Info("Reloading Telegram bot with new token...")
}
}
}
// resolveToken returns the bot token from DB (configured via Web UI).
func resolveToken(cfg *config.Config, st *store.Store) string {
dbCfg, err := st.TelegramConfig().Get()
if err == nil && dbCfg.BotToken != "" {
return dbCfg.BotToken
}
return ""
}
// runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false).
func runBot(token string, cfg *config.Config, st *store.Store) bool {
bot, err := tgbotapi.NewBotAPI(token)
if err != nil {
logger.Errorf("Telegram bot failed to start: %v", err)
return false
}
logger.Infof("Telegram bot @%s started", bot.Self.UserName)
// Allowed chat ID: read from DB binding (0 = unbound, first /start will bind).
allowedChatID := int64(0)
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
allowedChatID = id
}
// botUserID / botToken / agents are resolved lazily and refresh when user registers.
var (
botUserID string
botUserEmail string
botToken string
agents *agent.Manager
)
resolveBotUser := func() bool {
users, err := st.User().GetAll()
if err != nil || len(users) == 0 {
return false
}
u := users[0]
if u.ID == botUserID {
return true
}
newToken, err := agent.GenerateBotToken(u.ID)
if err != nil {
logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err)
return false
}
prev := botUserID
botUserID = u.ID
botUserEmail = u.Email
botToken = newToken
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID,
func() mcp.AIClient { return newLLMClient(st, botUserID) },
api.GetAPIDocs(),
)
if prev == "" {
logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail)
} else {
logger.Infof("Bot: user changed → %s (%s)", botUserID, botUserEmail)
}
return true
}
resolveBotUser()
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates := bot.GetUpdatesChan(u)
// awaitingLang is set only when the user explicitly runs /lang.
awaitingLang := false
for update := range updates {
if update.Message == nil {
continue
}
chatID := update.Message.Chat.ID
text := strings.TrimSpace(update.Message.Text)
// ── Language selection (triggered only by /lang) ──────────────────────
if awaitingLang && chatID == allowedChatID {
if lang := parseLangChoice(text); lang != "" {
awaitingLang = false
st.TelegramConfig().SetLanguage(lang) //nolint:errcheck
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
} else {
sendMarkdownMsg(bot, chatID, langMenuMsg())
}
continue
}
// ── /start ────────────────────────────────────────────────────────────
if text == "/start" {
resolveBotUser()
if botUserID == "" {
sendMsg(bot, chatID,
"No account found.\nOpen the web dashboard to register, then send /start.")
continue
}
if allowedChatID == 0 {
username := update.Message.From.UserName
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
logger.Errorf("Failed to bind Telegram user: %v", err)
sendMsg(bot, chatID, "Binding failed. Please try again.")
continue
}
allowedChatID = chatID
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
} else if chatID != allowedChatID {
sendMsg(bot, chatID, "This bot is already bound to another account.")
continue
} else {
agents.Reset(chatID)
}
lang := st.TelegramConfig().GetLanguage()
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
continue
}
// ── /lang ─────────────────────────────────────────────────────────────
if text == "/lang" {
awaitingLang = true
sendMarkdownMsg(bot, chatID, langMenuMsg())
continue
}
// ── /help ─────────────────────────────────────────────────────────────
if text == "/help" {
lang := st.TelegramConfig().GetLanguage()
sendMarkdownMsg(bot, chatID, helpMsg(lang))
continue
}
// ── Access control ────────────────────────────────────────────────────
if allowedChatID != 0 && chatID != allowedChatID {
sendMsg(bot, chatID, "Unauthorized.")
continue
}
if allowedChatID == 0 {
sendMsg(bot, chatID, "Send /start first.")
continue
}
if text == "" {
continue
}
// ── Refresh user before every AI call ────────────────────────────────
resolveBotUser()
if botUserID == "" {
sendMsg(bot, chatID, "No account found. Open the web dashboard to register.")
continue
}
lang := st.TelegramConfig().GetLanguage()
// ── Guard: show status if not ready for trading ───────────────────────
if newLLMClient(st, botUserID) == nil {
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
continue
}
// ── AI agent ─────────────────────────────────────────────────────────
go func(chatID int64, text string) {
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
placeholderID := 0
if err == nil {
placeholderID = sent.MessageID
}
var (
mu sync.Mutex
lastEdit time.Time
)
onChunk := func(accumulated string) {
if placeholderID == 0 {
return
}
mu.Lock()
defer mu.Unlock()
if accumulated != "⏳" && time.Since(lastEdit) < time.Second {
return
}
lastEdit = time.Now()
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
bot.Send(edit) //nolint:errcheck
}
reply := agents.Run(chatID, text, onChunk)
if placeholderID != 0 {
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
edit.ParseMode = "Markdown"
if _, err := bot.Send(edit); err != nil {
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
bot.Send(edit2) //nolint:errcheck
}
} else {
msg := tgbotapi.NewMessage(chatID, reply)
msg.ParseMode = "Markdown"
if _, err := bot.Send(msg); err != nil {
msg.ParseMode = ""
bot.Send(msg) //nolint:errcheck
}
}
}(chatID, text)
}
return true
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
bot.Send(msg) //nolint:errcheck
}
func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = "Markdown"
if _, err := bot.Send(msg); err != nil {
plain := tgbotapi.NewMessage(chatID, text)
bot.Send(plain) //nolint:errcheck
}
}
// ── LLM client ───────────────────────────────────────────────────────────────
func newLLMClient(st *store.Store, userID string) mcp.AIClient {
// 1. Prefer the model explicitly configured for Telegram (Settings → Telegram → AI Model)
if tgCfg, err := st.TelegramConfig().Get(); err == nil && tgCfg.ModelID != "" {
if model, err := st.AIModel().Get(userID, tgCfg.ModelID); err == nil && model.Enabled {
apiKey := string(model.APIKey)
if apiKey != "" {
client := clientForProvider(model.Provider)
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
if isUSDCProvider(model.Provider) {
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
} else {
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
}
return client
}
}
}
// 2. Fall back to first enabled model
if model, err := st.AIModel().GetDefault(userID); err == nil {
apiKey := string(model.APIKey)
if apiKey != "" {
client := clientForProvider(model.Provider)
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
if isUSDCProvider(model.Provider) {
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
} else {
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
}
return client
}
}
// 3. Environment variable fallback
for _, pair := range []struct{ provider, key, url string }{
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
} {
if pair.key != "" {
client := clientForProvider(pair.provider)
client.SetAPIKey(pair.key, pair.url, "")
return client
}
}
return nil
}
// isUSDCProvider returns true for providers that pay per call with USDC (x402 protocol).
func isUSDCProvider(provider string) bool {
return provider == "blockrun-base" || provider == "blockrun-sol" || provider == "claw402"
}
func clientForProvider(provider string) mcp.AIClient {
switch provider {
case "openai":
return mcp.NewOpenAIClient()
case "deepseek":
return mcp.NewDeepSeekClient()
case "claude":
return mcp.NewClaudeClient()
case "qwen":
return mcp.NewQwenClient()
case "kimi":
return mcp.NewKimiClient()
case "grok":
return mcp.NewGrokClient()
case "gemini":
return mcp.NewGeminiClient()
case "minimax":
return mcp.NewMiniMaxClient()
case "blockrun-base":
return mcp.NewBlockRunBaseClient()
case "blockrun-sol":
return mcp.NewBlockRunSolClient()
case "claw402":
return mcp.NewClaw402Client()
default:
return mcp.NewDeepSeekClient()
}
}
// ── Status message ────────────────────────────────────────────────────────────
// statusMsg is the single entry-point message shown after /start.
// It checks what's configured and shows either a setup prompt or the ready state.
func statusMsg(st *store.Store, userID string, apiPort int, lang string) string {
webURL := "http://localhost:3000"
// Determine what's missing.
hasModel := false
if _, err := st.AIModel().GetDefault(userID); err == nil {
hasModel = true
}
hasExchange := false
if exchanges, err := st.Exchange().List(userID); err == nil {
for _, e := range exchanges {
if e.Enabled {
hasExchange = true
break
}
}
}
if !hasModel || !hasExchange {
missing := ""
if lang == "zh" {
if !hasModel {
missing += "\n❌ AI 模型 → 设置 → AI 模型 → 添加"
}
if !hasExchange {
missing += "\n❌ 交易所 → 设置 → 交易所 → 添加"
}
return "⚙️ *需要完成初始配置*\n\n打开 Web 管理界面完成配置:\n→ " + webURL + "\n" + missing + "\n\n配置完成后发送 /start"
}
if !hasModel {
missing += "\n❌ AI Model → Settings → AI Models → Add"
}
if !hasExchange {
missing += "\n❌ Exchange → Settings → Exchanges → Add"
}
return "⚙️ *Setup required*\n\nOpen the web dashboard to complete setup:\n→ " + webURL + "\n" + missing + "\n\nSend /start when done."
}
// All configured — show ready state.
if lang == "zh" {
return `✅ *NOFX 就绪,开始交易吧!*
直接告诉我你想做什么:
📊 "查看我的持仓"
💰 "账户余额多少"
🤖 "帮我创建 BTC 趋势策略并启动"
⏹ "停止所有交易员"
/help 查看更多 · /lang 切换语言`
}
return `✅ *NOFX is ready!*
Just tell me what you want:
📊 "Show my positions"
💰 "What's my balance?"
🤖 "Create a BTC trend strategy and start it"
⏹ "Stop all traders"
/help for more · /lang to change language`
}
// ── Language ──────────────────────────────────────────────────────────────────
func langMenuMsg() string {
return "🌐 *Choose your language*\n\n1 — English\n2 — 中文\n\nReply with 1 or 2"
}
func parseLangChoice(text string) string {
switch strings.TrimSpace(text) {
case "1", "en", "EN", "English", "english":
return "en"
case "2", "zh", "ZH", "中文", "chinese", "Chinese":
return "zh"
}
return ""
}
// ── Help ──────────────────────────────────────────────────────────────────────
func helpMsg(lang string) string {
if lang == "zh" {
return `*NOFX 使用指南*
*查询*
• "查看我的持仓"
• "账户余额多少"
• "列出我的交易员"
*创建 & 启动*
• "帮我创建 BTC 趋势策略并跑起来"
• "保守型策略,只交易 BTC 和 ETH"
*控制*
• "启动交易员"
• "暂停交易员"
• "停止所有交易"
*命令*
/start — 刷新状态
/lang — 切换语言
/help — 帮助`
}
return `*NOFX Help*
*Query*
• "Show my positions"
• "What's my balance?"
• "List my traders"
*Create & start*
• "Create a BTC trend strategy and start it"
• "Conservative strategy, BTC and ETH only"
*Control*
• "Start trader"
• "Stop trader"
• "Stop all trading"
*Commands*
/start — refresh status
/lang — change language
/help — show this`
}

105
telegram/session/memory.go Normal file
View File

@@ -0,0 +1,105 @@
package session
import (
"fmt"
"nofx/mcp"
"strings"
)
const (
compactionThresholdTokens = 3000
charsPerToken = 3 // rough estimate for token counting
)
type Message struct {
Role string // "user" or "assistant"
Content string
}
// Memory manages conversation history with automatic compaction.
// Inspired by openclaw's compaction pattern:
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
type Memory struct {
LongTerm string // Durable summary (survives compaction, user never sees this happen)
ShortTerm []Message // Recent conversation (cleared on compaction)
llm mcp.AIClient
}
func NewMemory(llm mcp.AIClient) *Memory {
return &Memory{llm: llm}
}
// Add appends a message and triggers compaction if threshold exceeded
func (m *Memory) Add(role, content string) {
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
if m.estimateTokens() > compactionThresholdTokens {
m.compact()
}
}
// BuildContext returns context string for the agent's conversation history.
func (m *Memory) BuildContext() string {
var sb strings.Builder
if m.LongTerm != "" {
sb.WriteString("[Summary of earlier conversation]\n")
sb.WriteString(m.LongTerm)
sb.WriteString("\n\n")
}
if len(m.ShortTerm) > 0 {
sb.WriteString("[Recent conversation]\n")
for _, msg := range m.ShortTerm {
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
}
}
return sb.String()
}
// Reset clears short-term history (LongTerm preserved intentionally)
func (m *Memory) Reset() {
m.ShortTerm = []Message{}
}
// ResetFull clears everything including long-term memory
func (m *Memory) ResetFull() {
m.ShortTerm = []Message{}
m.LongTerm = ""
}
func (m *Memory) estimateTokens() int {
total := len(m.LongTerm)
for _, msg := range m.ShortTerm {
total += len(msg.Content)
}
return total / charsPerToken
}
// compact summarizes short-term history into long-term memory.
// This runs silently - the user never sees it happen.
// If LLM call fails, short-term is preserved as-is (no data loss).
func (m *Memory) compact() {
if m.llm == nil || len(m.ShortTerm) == 0 {
return
}
history := m.BuildContext()
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
Must preserve:
- What the user is configuring (strategy/exchange/model/trader)
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
- Pending or missing parameters
- User preferences and requirements
Output: plain text summary, under 200 words.`
summary, err := m.llm.CallWithMessages(systemPrompt, history)
if err != nil {
// Compaction failed: keep short-term as-is, never lose user data
return
}
if m.LongTerm != "" {
m.LongTerm = m.LongTerm + "\n" + summary
} else {
m.LongTerm = summary
}
m.ShortTerm = []Message{}
}

View File

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

View File

@@ -1,4 +1,4 @@
package trader
package aster
import (
"context"
@@ -23,6 +23,7 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"nofx/trader/types"
)
// AsterTrader Aster trading platform implementation
@@ -1295,14 +1296,14 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
// GetClosedPnL gets recent closing trades from Aster
// Note: Aster does NOT have a position history API, only trade history.
// This returns individual closing trades for real-time position closure detection.
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
@@ -1330,7 +1331,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLR
}
}
records = append(records, ClosedPnLRecord{
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1366,7 +1367,7 @@ type AsterTradeRecord struct {
}
// GetTrades retrieves trade history from Aster
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 500
}
@@ -1381,24 +1382,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []TradeRecord{}, nil
return []types.TradeRecord{}, nil
}
var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []TradeRecord{}, nil
return []types.TradeRecord{}, nil
}
// Convert to unified TradeRecord format
var result []TradeRecord
var result []types.TradeRecord
for _, at := range asterTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Qty, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: at.Side,
@@ -1416,7 +1417,192 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Aster open orders
return []OpenOrder{}, nil
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 (
"context"
@@ -10,6 +10,8 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -19,8 +21,8 @@ import (
// AsterTraderTestSuite Aster trader test suite
// Inherits TraderTestSuite and adds Aster specific mock logic
type AsterTraderTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewAsterTraderTestSuite creates Aster test suite
@@ -191,7 +193,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
privateKey, _ := crypto.GenerateKey()
// Create mock trader using mock server's URL
trader := &AsterTrader{
traderInstance := &AsterTrader{
ctx: context.Background(),
user: "0x1234567890123456789012345678901234567890",
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
@@ -202,7 +204,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
}
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &AsterTraderTestSuite{
TraderTestSuite: baseSuite,
@@ -224,7 +226,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
// TestAsterTrader_InterfaceCompliance tests interface compliance
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*AsterTrader)(nil)
var _ types.Trader = (*AsterTrader)(nil)
}
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
@@ -277,21 +279,21 @@ func TestNewAsterTrader(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
if tt.wantError {
assert.Error(t, err)
if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains)
}
assert.Nil(t, trader)
assert.Nil(t, at)
} else {
assert.NoError(t, err)
assert.NotNil(t, trader)
if trader != nil {
assert.Equal(t, tt.user, trader.user)
assert.Equal(t, tt.signer, trader.signer)
assert.NotNil(t, trader.privateKey)
assert.NotNil(t, at)
if at != nil {
assert.Equal(t, tt.user, at.user)
assert.Equal(t, tt.signer, at.signer)
assert.NotNil(t, at.privateKey)
}
}
})

View File

@@ -4,12 +4,22 @@ import (
"encoding/json"
"fmt"
"math"
"nofx/kernel"
"nofx/experience"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
"nofx/trader/aster"
"nofx/trader/binance"
"nofx/trader/bitget"
"nofx/trader/bybit"
"nofx/trader/gate"
"nofx/trader/hyperliquid"
"nofx/trader/indodax"
"nofx/trader/kucoin"
"nofx/trader/lighter"
"nofx/trader/okx"
"strings"
"sync"
"time"
@@ -23,7 +33,7 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
@@ -35,19 +45,33 @@ type AutoTraderConfig struct {
BybitSecretKey string
// OKX API configuration
OKXAPIKey string
OKXSecretKey string
OKXAPIKey string
OKXSecretKey string
OKXPassphrase string
// Bitget API configuration
BitgetAPIKey string
BitgetSecretKey string
BitgetAPIKey string
BitgetSecretKey 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
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration
AsterUser string // Aster main wallet address
@@ -103,9 +127,9 @@ type AutoTrader struct {
config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms)
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)
cycleNumber int // Current cycle number
cycleNumber int // Current cycle number
initialBalance float64
dailyPnL float64
customPrompt string // Custom trading strategy prompt
@@ -123,6 +147,7 @@ type AutoTrader struct {
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
lastBalanceSyncTime time.Time // Last balance sync time
userID string // User ID
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
}
// NewAutoTrader creates an automatic trader
@@ -176,6 +201,26 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
case "minimax":
mcpClient = mcp.NewMiniMaxClient()
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
case "blockrun-base":
mcpClient = mcp.NewBlockRunBaseClient()
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
logger.Infof("🤖 [%s] Using BlockRun (Base Wallet) AI", config.Name)
case "blockrun-sol":
mcpClient = mcp.NewBlockRunSolClient()
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name)
case "claw402":
mcpClient = mcp.NewClaw402Client()
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name)
case "qwen":
mcpClient = mcp.NewQwenClient()
apiKey := config.QwenKey
@@ -223,25 +268,31 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
switch config.Exchange {
case "binance":
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
case "gate":
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
case "kucoin":
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
}
@@ -253,7 +304,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
}
// Lighter only supports mainnet (testnet disabled)
trader, err = NewLighterTraderV2(
trader, err = lighter.NewLighterTraderV2(
config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex,
@@ -263,6 +314,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
}
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:
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
}
@@ -362,7 +416,7 @@ func (at *AutoTrader) Run() error {
// Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
}
@@ -370,7 +424,7 @@ func (at *AutoTrader) Run() error {
// Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
}
@@ -378,7 +432,7 @@ func (at *AutoTrader) Run() error {
// Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
}
@@ -386,7 +440,7 @@ func (at *AutoTrader) Run() error {
// Start OKX order sync if using OKX exchange
if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
}
@@ -394,7 +448,7 @@ func (at *AutoTrader) Run() error {
// Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
}
@@ -402,7 +456,7 @@ func (at *AutoTrader) Run() error {
// Start Aster order sync if using Aster exchange
if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
}
@@ -410,18 +464,50 @@ func (at *AutoTrader) Run() error {
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
// Start Gate order sync if using Gate exchange
if at.exchange == "gate" {
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
}
}
// Start KuCoin order sync if using KuCoin exchange
if at.exchange == "kucoin" {
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop()
// 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
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
if isGridStrategy {
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 {
@@ -435,8 +521,14 @@ func (at *AutoTrader) Run() error {
select {
case <-ticker.C:
if err := at.runCycle(); err != nil {
logger.Infof("❌ Execution failed: %v", err)
if isGridStrategy {
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:
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
@@ -512,8 +604,25 @@ func (at *AutoTrader) runCycle() error {
}
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
// NOTE: Must be called BEFORE candidate coins check to ensure equity is always recorded
at.saveEquitySnapshot(ctx)
// 如果没有候选币种,记录但不报错
if len(ctx.CandidateCoins) == 0 {
logger.Infof(" No candidate coins available, skipping this cycle")
record.Success = true // 不是错误,只是没有候选币
record.ExecutionLog = append(record.ExecutionLog, "No candidate coins available, cycle skipped")
record.AccountState = store.AccountSnapshot{
TotalBalance: ctx.Account.TotalEquity,
AvailableBalance: ctx.Account.AvailableBalance,
TotalUnrealizedProfit: ctx.Account.UnrealizedPnL,
PositionCount: ctx.Account.PositionCount,
InitialBalance: at.initialBalance,
}
at.saveDecision(record)
return nil
}
logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
@@ -792,14 +901,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
}
// 3. Use strategy engine to get candidate coins (must have strategy engine)
var candidateCoins []kernel.CandidateCoin
if at.strategyEngine == nil {
return nil, fmt.Errorf("trader has no strategy engine configured")
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
} else {
coins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
// Log warning but don't fail - equity snapshot should still be saved
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
} else {
candidateCoins = coins
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
}
}
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
if err != nil {
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
}
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
// 4. Calculate total P&L
totalPnL := totalEquity - at.initialBalance
@@ -1021,7 +1135,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
}
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1138,7 +1252,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
}
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1237,7 +1351,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1301,7 +1415,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price
marketData, err := market.Get(decision.Symbol)
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
if err != nil {
return err
}
@@ -1365,6 +1479,12 @@ func (at *AutoTrader) GetID() string {
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
func (at *AutoTrader) GetName() string {
return at.name
@@ -1471,7 +1591,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
isRunning := at.isRunning
at.isRunningMutex.RUnlock()
return map[string]interface{}{
result := map[string]interface{}{
"trader_id": at.id,
"trader_name": at.name,
"ai_model": at.aiModel,
@@ -1486,6 +1606,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
"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)
@@ -1881,7 +2011,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// This ensures accurate data from GetTrades API and avoids duplicate records
switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return
}
@@ -2079,22 +2209,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
normalizedSymbol := market.Normalize(symbol)
fill := &store.TraderFill{
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now().UTC().UnixMilli(),
TraderID: at.id,
ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID,
Symbol: normalizedSymbol,
Side: side,
Price: price,
Quantity: quantity,
QuoteQuantity: price * quantity,
Commission: fee,
CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now().UTC().UnixMilli(),
}
// Calculate realized PnL for close orders
@@ -2222,4 +2352,3 @@ func getSideFromAction(action string) string {
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 (
"context"
@@ -7,6 +7,7 @@ import (
"fmt"
"nofx/hook"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
@@ -716,6 +717,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
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)
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
@@ -777,8 +897,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
@@ -794,7 +914,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
@@ -817,7 +937,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
@@ -1035,6 +1155,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
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
func contains(s, substr string) bool {
return len(s) >= len(substr) && stringContains(s, substr)
@@ -1092,14 +1248,14 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
// Note: Binance does NOT have a position history API, only trade history.
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
// NOT suitable for historical position reconstruction - use only for matching recent closures.
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue // Skip opening trades
@@ -1128,7 +1284,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
}
}
records = append(records, ClosedPnLRecord{
records = append(records, types.ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1149,7 +1305,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
// GetTrades retrieves trade history from Binance Futures using Income API
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1167,7 +1323,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
return nil, fmt.Errorf("failed to get income history: %w", err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 {
@@ -1176,7 +1332,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol,
RealizedPnL: pnl,
@@ -1192,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
// GetTradesForSymbol retrieves trade history for a specific symbol
// This is more reliable than using Income API which may have delays
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1209,14 +1365,14 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),
@@ -1235,7 +1391,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
// This is used for incremental sync - only fetch new trades since last sync
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1252,14 +1408,14 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
}
var trades []TradeRecord
var trades []types.TradeRecord
for _, at := range accountTrades {
price, _ := strconv.ParseFloat(at.Price, 64)
qty, _ := strconv.ParseFloat(at.Quantity, 64)
fee, _ := strconv.ParseFloat(at.Commission, 64)
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
trade := TradeRecord{
trade := types.TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),

View File

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

View File

@@ -1,10 +1,11 @@
package trader
package binance
import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/store"
"nofx/trader/types"
"sort"
"strings"
"sync"
@@ -56,12 +57,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
}
}
// Record current time BEFORE querying, to avoid missing trades during sync
// This prevents race condition where trades happen between query and lastSyncTime update
syncStartTimeMs := nowMs
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
// Step 1: Get max trade IDs from local DB for incremental sync
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
@@ -100,18 +97,17 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
symbolMap[s] = true
}
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
if len(symbolMap) == 0 {
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
if err != nil {
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
} else {
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
}
// IMPORTANT: Must run always, not just when symbolMap is empty,
// because a position might be fully closed (no active position) but have PnL
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
if err != nil {
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
} else {
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
for _, s := range pnlSymbols {
symbolMap[s] = true
}
}
@@ -122,21 +118,20 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync")
// Update last sync time even if no changes
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock()
// DON'T update lastSyncTime to current time here!
// Keep using the last actual trade time from DB to avoid creating gaps
// The lastSyncTimeMs from DB already has +1000ms buffer added
return nil
}
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols)
// Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols)
var allTrades []TradeRecord
var allTrades []types.TradeRecord
var failedSymbols []string
apiCalls := 0
for _, symbol := range changedSymbols {
var trades []TradeRecord
var trades []types.TradeRecord
var queryErr error
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
@@ -158,17 +153,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
// Only update last sync time if ALL symbols were successfully queried
// This prevents data loss when some symbols fail due to rate limit or network issues
if len(failedSymbols) == 0 {
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock()
} else {
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
}
if len(allTrades) == 0 {
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
// Don't update lastSyncTime, keep using DB value
if len(failedSymbols) > 0 {
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
}
return nil
}
@@ -182,10 +172,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0
skippedCount := 0
for _, trade := range allTrades {
// Check if trade already exists
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
skippedCount++
continue // Trade already exists, skip
}
@@ -280,7 +272,21 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
trade.Time.UTC().Format("01-02 15:04:05"))
}
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
// Update lastSyncTime to the LATEST trade time (not current time!)
// This ensures next sync starts from where we left off, not from "now"
// allTrades is already sorted by time ASC, so last element is the latest
if len(allTrades) > 0 && len(failedSymbols) == 0 {
latestTradeTimeMs := allTrades[len(allTrades)-1].Time.UTC().UnixMilli()
binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = latestTradeTimeMs
binanceSyncStateMutex.Unlock()
logger.Infof("📅 Updated lastSyncTime to latest trade: %s (UTC)",
time.UnixMilli(latestTradeTimeMs).UTC().Format("2006-01-02 15:04:05"))
} else if len(failedSymbols) > 0 {
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
}
logger.Infof("✅ Binance order sync completed: %d new trades synced, %d skipped (already exist)", syncedCount, skippedCount)
return nil
}

View File

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

View File

@@ -1,4 +1,4 @@
package trader
package binance
import (
"nofx/store"
@@ -92,7 +92,7 @@ func TestBinanceSyncE2E(t *testing.T) {
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,
order.FilledAt.Format(time.RFC3339))
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
}
}
@@ -118,10 +118,11 @@ func TestBinanceSyncE2E(t *testing.T) {
}
// Test GetLastFillTimeByExchange
lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID)
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)
@@ -175,7 +176,7 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
Price: 50000,
Quantity: 0.001,
QuoteQuantity: 50,
CreatedAt: localTime, // This time is "in the future" if interpreted as UTC
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)
@@ -186,10 +187,11 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
// Check GetLastFillTimeByExchange
lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339))
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
if lastFillTime.After(time.Now().UTC()) {
if lastFillTime2.After(time.Now().UTC()) {
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package trader
package bitget
import (
"bytes"
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"time"
"nofx/trader/types"
)
// Bitget API endpoints (V2)
@@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
}
// GetClosedPnL retrieves closed position PnL records
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1051,9 +1052,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
return nil, fmt.Errorf("failed to parse response: %w", err)
}
records := make([]ClosedPnLRecord, 0, len(resp.List))
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
for _, pos := range resp.List {
record := ClosedPnLRecord{
record := types.ClosedPnLRecord{
Symbol: pos.Symbol,
Side: pos.HoldSide,
}
@@ -1098,7 +1099,262 @@ func genBitgetClientOid() string {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Bitget open orders
return []OpenOrder{}, nil
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 (
"crypto/hmac"

View File

@@ -1,4 +1,4 @@
package trader
package bybit
import (
"context"
@@ -17,6 +17,7 @@ import (
"time"
bybit "github.com/bybit-exchange/bybit.go.api"
"nofx/trader/types"
)
// BybitTrader Bybit USDT Perpetual Futures Trader
@@ -900,13 +901,13 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
}
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
return t.getClosedPnLViaHTTP(startTime, limit)
}
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
// Build query string
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
@@ -967,14 +968,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]Clo
}
// parseClosedPnLResult parses the closed PnL result from Bybit API
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
data, ok := resultData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
list, _ := data["list"].([]interface{})
var records []ClosedPnLRecord
var records []types.ClosedPnLRecord
for _, item := range list {
pnl, ok := item.(map[string]interface{})
@@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
normalizedSide = "short"
}
record := ClosedPnLRecord{
record := types.ClosedPnLRecord{
Symbol: symbol,
Side: normalizedSide,
EntryPrice: avgEntryPrice,
@@ -1046,8 +1047,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
@@ -1088,7 +1089,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
displayType = stopOrderType
}
result = append(result, OpenOrder{
result = append(result, types.OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
@@ -1105,3 +1106,159 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
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 (
"encoding/json"
@@ -9,6 +9,8 @@ import (
"time"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -18,8 +20,8 @@ import (
// BybitTraderTestSuite Bybit trader test suite
// Inherits TraderTestSuite and adds Bybit-specific mock logic
type BybitTraderTestSuite struct {
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewBybitTraderTestSuite Create Bybit test suite
@@ -66,10 +68,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
}))
// Create real Bybit trader (for interface compliance testing)
trader := NewBybitTrader("test_api_key", "test_secret_key")
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
// Create base suite
baseSuite := NewTraderTestSuite(t, trader)
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
return &BybitTraderTestSuite{
TraderTestSuite: baseSuite,
@@ -91,7 +93,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
// TestBybitTrader_InterfaceCompliance Test interface compliance
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
var _ Trader = (*BybitTrader)(nil)
var _ types.Trader = (*BybitTrader)(nil)
}
// ============================================================
@@ -128,13 +130,13 @@ func TestNewBybitTrader(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trader := NewBybitTrader(tt.apiKey, tt.secretKey)
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
if tt.wantNil {
assert.Nil(t, trader)
assert.Nil(t, bt)
} else {
assert.NotNil(t, trader)
assert.NotNil(t, trader.client)
assert.NotNil(t, bt)
assert.NotNil(t, bt.client)
}
})
}
@@ -176,7 +178,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
// TestBybitTrader_FormatQuantity Test quantity formatting
func TestBybitTrader_FormatQuantity(t *testing.T) {
trader := NewBybitTrader("test", "test")
bt := NewBybitTrader("test", "test")
tests := []struct {
name string
@@ -210,7 +212,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := trader.FormatQuantity(tt.symbol, tt.quantity)
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
if tt.hasError {
assert.Error(t, err)
} else {
@@ -335,19 +337,19 @@ func convertBybitSide(side string) string {
// TestBybitTrader_CategoryLinear Test using only linear category
func TestBybitTrader_CategoryLinear(t *testing.T) {
// Bybit trader should only use linear category (USDT perpetual contracts)
trader := NewBybitTrader("test", "test")
assert.NotNil(t, trader)
bt := NewBybitTrader("test", "test")
assert.NotNil(t, bt)
// Verify default configuration
assert.NotNil(t, trader.client)
assert.NotNil(t, bt.client)
}
// TestBybitTrader_CacheDuration Test cache duration
func TestBybitTrader_CacheDuration(t *testing.T) {
trader := NewBybitTrader("test", "test")
bt := NewBybitTrader("test", "test")
// Verify default cache time is 15 seconds
assert.Equal(t, 15*time.Second, trader.cacheDuration)
assert.Equal(t, 15*time.Second, bt.cacheDuration)
}
// ============================================================

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