3 Commits

Author SHA1 Message Date
tinklefund
8b4ce279da feat: Add powerful flexible strategy system
Strategy Builder:
- Create strategies from natural language
- Grid trading strategy
- DCA (Dollar Cost Averaging) strategy
- Trend following (EMA crossover) strategy
- Custom rule-based strategies

Strategy Components:
- Entry/exit rules with indicators (RSI, EMA, MACD, etc.)
- Position sizing (fixed, percent, risk-based, kelly)
- Risk management (max drawdown, daily loss limit, cooldown)
- Leverage config (fixed, dynamic, per-symbol, per-volatility)
- Time-based rules (trading hours, hold time limits)
- AI enhancement (confidence threshold, personality)

11 New Tools:
- create_strategy - Natural language strategy creation
- create_grid_strategy - Grid trading setup
- create_dca_strategy - DCA setup
- create_trend_strategy - Trend following setup
- list_smart_strategies - List all strategies
- get_strategy_details - Strategy details
- update_strategy - Modify strategy settings
- activate_strategy - Start trading
- deactivate_strategy - Stop trading
- delete_strategy - Remove strategy
- get_strategy_templates - Show available templates

Total tools now: 24 (13 trading + 11 strategy)
2026-01-30 03:45:10 +08:00
tinklefund
f9d8318869 feat: Add smart trading assistant with context awareness
- Add SmartAgent with automatic context injection
  - Real-time portfolio/position data in every prompt
  - AI knows current state before responding

- Add TradingContext builder
  - Aggregates balance, positions, P&L across all traders
  - Auto-generates alerts (liquidation risk, large loss, etc.)

- Add background Monitor
  - Proactive position monitoring every 30s
  - Detects new positions, closed positions
  - Forwards alerts to Telegram

- Enhanced system prompts
  - Professional trading assistant persona
  - Risk assessment guidelines
  - Clear response formatting rules

Features:
 Context-aware responses
 Proactive risk alerts
 Background monitoring
 Alert broadcasting to Telegram
2026-01-30 03:40:14 +08:00
tinklefund
01ba348841 feat: Add Telegram AI Assistant (moltbot-nofx integration)
- Add assistant package with AI Agent runtime
  - agent.go: Core agent loop with tool calling
  - session.go: Conversation memory management
  - tool.go: Tool interface and base implementation
  - trading_tools.go: Trading-specific tools (13 tools)
  - prompts.go: Trading expert system prompts (EN/ZH)

- Add telegram package for Telegram bot integration
  - bot.go: Telegram bot with rate limiting & access control
  - config.go: Environment-based configuration

- Update main.go to initialize Telegram bot on startup
- Update .env.example with new configuration options
- Add gopkg.in/telebot.v3 dependency

Trading tools available:
- Query: get_balance, get_positions, list_traders, get_trader_status
- Control: start_trader, stop_trader
- Trading: get_market_price, open_long, open_short, close_position
- Config: list_strategies, list_exchanges, list_ai_models
2026-01-30 03:29:22 +08:00
173 changed files with 8499 additions and 19455 deletions

View File

@@ -49,9 +49,52 @@ RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRI
TRANSPORT_ENCRYPTION=false
# ===========================================
# Optional: External Services
# Telegram AI Assistant (NEW - moltbot-nofx)
# ===========================================
# Telegram Bot Token (get from @BotFather)
# This enables the AI trading assistant via Telegram
TELEGRAM_BOT_TOKEN=
# Allowed users (comma-separated Telegram user IDs)
# Leave empty to allow all users (not recommended for production)
# Get your ID from @userinfobot
TELEGRAM_ALLOWED_USERS=
# Admin users (comma-separated Telegram user IDs)
# Admins can manage bot settings
TELEGRAM_ADMIN_USERS=
# Rate limit (messages per minute per user)
TELEGRAM_RATE_LIMIT=30
# Default language: "en" or "zh"
TELEGRAM_LANGUAGE=zh
# ===========================================
# AI Model Configuration (for Assistant)
# ===========================================
# DeepSeek (recommended - cost-effective)
DEEPSEEK_API_KEY=
DEEPSEEK_API_URL=
DEEPSEEK_MODEL=deepseek-chat
# Claude (optional alternative)
CLAUDE_API_KEY=
CLAUDE_API_URL=
CLAUDE_MODEL=
# OpenAI (optional alternative)
OPENAI_API_KEY=
OPENAI_API_URL=
OPENAI_MODEL=
# Qwen (optional alternative)
QWEN_API_KEY=
QWEN_API_URL=
QWEN_MODEL=
DB_TYPE=postgres
DB_HOST=10.
DB_PORT=5432

View File

@@ -1,50 +1,100 @@
## Summary
# Pull Request
- Problem:
- What changed:
- What did NOT change (scope boundary):
> **📋 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
## Change Type
---
- [ ] Bug fix
- [ ] Feature
- [ ] Refactoring
- [ ] Docs
- [ ] Security fix
- [ ] Chore / infra
> **💡 Tip:** Recommended PR title format `type(scope): description`
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
## Scope
---
- [ ] Trading engine / strategies
- [ ] MCP / AI clients
- [ ] API / server
- [ ] Telegram bot / agent
- [ ] Web UI / frontend
- [ ] Config / deployment
- [ ] CI/CD / infra
## 📝 Description
## Linked Issues
<!-- 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
- Closes #
- Related #
- Related to #
## Testing
---
What you verified and how:
## 📋 Changes Made
- [ ] `go build ./...` passes
- [ ] `go test ./...` passes
- [ ] Manual testing done (describe below)
<!-- List the specific changes made -->
-
-
## Security Impact
---
- Secrets/keys handling changed? (`Yes/No`)
- New/changed API endpoints? (`Yes/No`)
- User input validation affected? (`Yes/No`)
## 🧪 Testing
## Compatibility
- [ ] Tested locally
- [ ] Tests pass
- [ ] Verified no existing functionality broke
- Backward compatible? (`Yes/No`)
- Config/env changes? (`Yes/No`)
- Migration needed? (`Yes/No`)
- If yes, upgrade steps:
---
## ✅ 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!**

View File

@@ -1,18 +1,22 @@
name: PR Labeler
name: PR Template Suggester
on:
pull_request:
types: [opened, synchronize, reopened]
types: [opened, edited, synchronize]
permissions:
pull-requests: write
issues: write
contents: read
jobs:
label-pr:
suggest-template:
runs-on: ubuntu-latest
steps:
- name: Analyze PR and apply labels
- name: Checkout code
uses: actions/checkout@v4
- name: Analyze PR files and auto-apply template
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -21,72 +25,166 @@ 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 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++;
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++;
else otherFiles++;
}
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
if (totalFiles === 0) return;
if (totalFiles === 0) { console.log('No files changed'); return; }
// --- 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');
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
// --- 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' });
}
}
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';
}
// 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({
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: labels,
pull_number: context.issue.number,
});
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
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');
}

2
.gitignore vendored
View File

@@ -16,7 +16,6 @@ nofx_test
# Go 相关
*.test
*.out
.gocache/
# 操作系统
.DS_Store
@@ -125,3 +124,4 @@ dmypy.json
# Pyre type checker
.pyre/
PR_DESCRIPTION.md
nofx-moltbot

View File

@@ -103,43 +103,6 @@ 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,21 +1,9 @@
<h1 align="center">NOFX — Open Source AI Trading OS</h1>
# NOFX - Agentic Trading OS
<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>
[![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)
| CONTRIBUTOR AIRDROP PROGRAM |
|:----------------------------------:|
@@ -26,6 +14,10 @@
---
## 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 |
@@ -38,7 +30,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, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, 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
@@ -78,35 +70,33 @@ To use NOFX, you'll need:
### CEX (Centralized Exchanges)
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|----------|--------|-------------------------|
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (Decentralized Perpetual Exchanges)
| Exchange | Status | Register (Fee Discount) |
|:---------|:------:|:------------------------|
| <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) |
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
---
## Supported AI Models
| AI Model | Status | Get API Key |
|:---------|:------:|:------------|
| <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) |
|----------|--------|-------------|
| **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) |
---

View File

@@ -832,8 +832,6 @@ 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 {

252
api/register_otp_test.go Normal file
View File

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

View File

@@ -1,66 +0,0 @@
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,8 +136,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
})
}
// handleCreateStrategy Create strategy.
// If "config" is omitted from the request body, the system default config is used automatically.
// handleCreateStrategy Create strategy
func (s *Server) handleCreateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
if userID == "" {
@@ -146,10 +145,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
}
var req struct {
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
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -157,16 +155,6 @@ 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 {
@@ -190,7 +178,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,
@@ -203,10 +191,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// 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.
// handleUpdateStrategy Update strategy
func (s *Server) handleUpdateStrategy(c *gin.Context) {
userID := c.GetString("user_id")
strategyID := c.Param("id")
@@ -228,11 +213,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
}
var req struct {
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"`
Name string `json:"name"`
Description string `json:"description"`
Config store.StrategyConfig `json:"config"`
IsPublic bool `json:"is_public"`
ConfigVisible bool `json:"config_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -240,33 +225,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// 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)
// Serialize configuration
configJSON, err := json.Marshal(req.Config)
if err != nil {
SafeInternalError(c, "Serialize configuration", err)
return
@@ -275,8 +235,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
strategy := &store.Strategy{
ID: strategyID,
UserID: userID,
Name: name,
Description: description,
Name: req.Name,
Description: req.Description,
Config: string(configJSON),
IsPublic: req.IsPublic,
ConfigVisible: req.ConfigVisible,
@@ -287,8 +247,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
return
}
// Validate merged configuration and collect warnings
warnings := validateStrategyConfig(&mergedConfig)
// Validate configuration and collect warnings
warnings := validateStrategyConfig(&req.Config)
response := gin.H{"message": "Strategy updated successfully"}
if len(warnings) > 0 {
@@ -665,18 +625,6 @@ 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()

350
assistant/agent.go Normal file
View File

@@ -0,0 +1,350 @@
// Package assistant implements the AI Agent runtime with tool calling capabilities
// Inspired by moltbot's agent architecture, specialized for trading
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/logger"
"nofx/mcp"
"strings"
"sync"
"time"
)
// Agent represents an AI assistant with tool-calling capabilities
type Agent struct {
// AI client for LLM calls
aiClient mcp.AIClient
// Tool registry
tools map[string]Tool
toolsLock sync.RWMutex
// Session/memory management
sessions map[string]*Session
sessionsLock sync.RWMutex
// Configuration
config AgentConfig
// System prompt
systemPrompt string
}
// AgentConfig holds agent configuration
type AgentConfig struct {
// Max tool calls per turn (prevent infinite loops)
MaxToolCalls int `json:"max_tool_calls"`
// Max conversation history to keep
MaxHistoryMessages int `json:"max_history_messages"`
// Timeout for single AI call
AITimeout time.Duration `json:"ai_timeout"`
// Model to use
Model string `json:"model"`
}
// DefaultAgentConfig returns sensible defaults
func DefaultAgentConfig() AgentConfig {
return AgentConfig{
MaxToolCalls: 10,
MaxHistoryMessages: 50,
AITimeout: 120 * time.Second,
Model: "deepseek-chat",
}
}
// NewAgent creates a new AI agent
func NewAgent(aiClient mcp.AIClient, config AgentConfig) *Agent {
agent := &Agent{
aiClient: aiClient,
tools: make(map[string]Tool),
sessions: make(map[string]*Session),
config: config,
}
// Set default system prompt
agent.systemPrompt = DefaultTradingSystemPrompt()
return agent
}
// RegisterTool adds a tool to the agent's toolkit
func (a *Agent) RegisterTool(tool Tool) {
a.toolsLock.Lock()
defer a.toolsLock.Unlock()
a.tools[tool.Name()] = tool
logger.Infof("🔧 Registered tool: %s", tool.Name())
}
// RegisterTools adds multiple tools
func (a *Agent) RegisterTools(tools ...Tool) {
for _, tool := range tools {
a.RegisterTool(tool)
}
}
// SetSystemPrompt sets the agent's system prompt
func (a *Agent) SetSystemPrompt(prompt string) {
a.systemPrompt = prompt
}
// GetSession returns or creates a session for the given ID
func (a *Agent) GetSession(sessionID string) *Session {
a.sessionsLock.Lock()
defer a.sessionsLock.Unlock()
if session, ok := a.sessions[sessionID]; ok {
return session
}
session := NewSession(sessionID, a.config.MaxHistoryMessages)
a.sessions[sessionID] = session
return session
}
// Chat processes a user message and returns the agent's response
// This is the main entry point for the agent loop
func (a *Agent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
session := a.GetSession(sessionID)
// Add user message to history
session.AddMessage(Message{
Role: "user",
Content: userMessage,
Timestamp: time.Now(),
})
// Build the full prompt with tools
systemPrompt := a.buildSystemPromptWithTools()
conversationPrompt := a.buildConversationPrompt(session)
// Agent loop - keep calling AI until it's done or max iterations
var finalResponse string
toolCallCount := 0
for {
// Check context cancellation
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Check max tool calls
if toolCallCount >= a.config.MaxToolCalls {
logger.Warnf("⚠️ Max tool calls reached (%d), stopping agent loop", a.config.MaxToolCalls)
break
}
// Call AI
response, err := a.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
// Parse response for tool calls
toolCalls, textResponse, err := a.parseResponse(response)
if err != nil {
// If parsing fails, treat entire response as text
finalResponse = response
break
}
// If no tool calls, we're done
if len(toolCalls) == 0 {
finalResponse = textResponse
break
}
// Execute tool calls
toolResults := a.executeToolCalls(ctx, toolCalls)
toolCallCount += len(toolCalls)
// Add tool calls and results to conversation for next iteration
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, please provide your response to the user:",
formatToolCalls(toolCalls),
formatToolResults(toolResults))
// If there's also a text response, capture it
if textResponse != "" {
finalResponse = textResponse
}
}
// Add assistant response to history
session.AddMessage(Message{
Role: "assistant",
Content: finalResponse,
Timestamp: time.Now(),
})
return &AgentResponse{
Text: finalResponse,
SessionID: sessionID,
}, nil
}
// buildSystemPromptWithTools creates the system prompt including tool definitions
func (a *Agent) buildSystemPromptWithTools() string {
a.toolsLock.RLock()
defer a.toolsLock.RUnlock()
var toolDefs []string
for _, tool := range a.tools {
toolDef := fmt.Sprintf(`- **%s**: %s
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
toolDefs = append(toolDefs, toolDef)
}
toolsSection := ""
if len(toolDefs) > 0 {
toolsSection = fmt.Sprintf(`
## Available Tools
You can call tools by responding with JSON in this format:
{"tool_calls": [{"name": "tool_name", "arguments": {"param": "value"}}]}
After receiving tool results, provide a natural language response to the user.
Tools:
%s
`, strings.Join(toolDefs, "\n"))
}
return a.systemPrompt + toolsSection
}
// buildConversationPrompt builds the conversation history as a prompt
func (a *Agent) buildConversationPrompt(session *Session) string {
messages := session.GetMessages()
var parts []string
for _, msg := range messages {
parts = append(parts, fmt.Sprintf("%s: %s", strings.Title(msg.Role), msg.Content))
}
return strings.Join(parts, "\n\n")
}
// parseResponse extracts tool calls and text from AI response
func (a *Agent) parseResponse(response string) ([]ToolCall, string, error) {
// Try to find JSON tool calls in response
// Look for {"tool_calls": [...]} pattern
var toolCalls []ToolCall
textResponse := response
// Try to parse as JSON
if strings.Contains(response, "tool_calls") {
// Find JSON block
start := strings.Index(response, "{")
end := strings.LastIndex(response, "}")
if start >= 0 && end > start {
jsonStr := response[start : end+1]
var parsed struct {
ToolCalls []struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(jsonStr), &parsed); err == nil {
for _, tc := range parsed.ToolCalls {
toolCalls = append(toolCalls, ToolCall{
Name: tc.Name,
Arguments: tc.Arguments,
})
}
// Extract text before/after JSON
textResponse = strings.TrimSpace(response[:start] + response[end+1:])
}
}
}
return toolCalls, textResponse, nil
}
// executeToolCalls runs the requested tools
func (a *Agent) executeToolCalls(ctx context.Context, calls []ToolCall) []ToolResult {
a.toolsLock.RLock()
defer a.toolsLock.RUnlock()
var results []ToolResult
for _, call := range calls {
tool, ok := a.tools[call.Name]
if !ok {
results = append(results, ToolResult{
Name: call.Name,
Error: fmt.Sprintf("unknown tool: %s", call.Name),
})
continue
}
logger.Infof("🔧 Executing tool: %s", call.Name)
result, err := tool.Execute(ctx, call.Arguments)
if err != nil {
logger.Errorf("❌ Tool %s failed: %v", call.Name, err)
results = append(results, ToolResult{
Name: call.Name,
Error: err.Error(),
})
} else {
logger.Infof("✅ Tool %s completed", call.Name)
results = append(results, ToolResult{
Name: call.Name,
Result: result,
})
}
}
return results
}
// ToolCall represents a tool invocation request from the AI
type ToolCall struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
Name string `json:"name"`
Result interface{} `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// AgentResponse is the final response from the agent
type AgentResponse struct {
Text string `json:"text"`
SessionID string `json:"session_id"`
}
func formatToolCalls(calls []ToolCall) string {
var parts []string
for _, c := range calls {
parts = append(parts, fmt.Sprintf("- %s(%s)", c.Name, string(c.Arguments)))
}
return strings.Join(parts, "\n")
}
func formatToolResults(results []ToolResult) string {
var parts []string
for _, r := range results {
if r.Error != "" {
parts = append(parts, fmt.Sprintf("- %s: ERROR: %s", r.Name, r.Error))
} else {
resultJSON, _ := json.Marshal(r.Result)
parts = append(parts, fmt.Sprintf("- %s: %s", r.Name, string(resultJSON)))
}
}
return strings.Join(parts, "\n")
}

312
assistant/context.go Normal file
View File

@@ -0,0 +1,312 @@
// Package assistant - Trading Context Builder
// Automatically enriches AI prompts with real-time market and portfolio data
package assistant
import (
"fmt"
"nofx/manager"
"nofx/store"
"strings"
"time"
)
// TradingContext holds real-time trading context for AI decision making
type TradingContext struct {
// Portfolio state
TotalEquity float64 `json:"total_equity"`
AvailableBalance float64 `json:"available_balance"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
Positions []PositionSummary `json:"positions"`
// Market data
MarketPrices map[string]float64 `json:"market_prices"`
PriceChanges24h map[string]float64 `json:"price_changes_24h"`
// Trader states
ActiveTraders []TraderSummary `json:"active_traders"`
// Alerts
Alerts []Alert `json:"alerts"`
// Timestamp
UpdatedAt time.Time `json:"updated_at"`
}
// PositionSummary summarizes a position
type PositionSummary struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // "long" or "short"
Size float64 `json:"size"`
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
PnLPercent float64 `json:"pnl_percent"`
Leverage int `json:"leverage"`
LiquidationPrice float64 `json:"liquidation_price,omitempty"`
TraderID string `json:"trader_id"`
TraderName string `json:"trader_name"`
}
// TraderSummary summarizes a trader's state
type TraderSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Exchange string `json:"exchange"`
IsRunning bool `json:"is_running"`
Equity float64 `json:"equity"`
PositionCount int `json:"position_count"`
TodayPnL float64 `json:"today_pnl,omitempty"`
}
// Alert represents a trading alert
type Alert struct {
Level string `json:"level"` // "info", "warning", "danger"
Type string `json:"type"` // "liquidation_risk", "large_loss", "price_alert", etc.
Message string `json:"message"`
}
// ContextBuilder builds trading context for AI
type ContextBuilder struct {
traderManager *manager.TraderManager
store *store.Store
}
// NewContextBuilder creates a context builder
func NewContextBuilder(tm *manager.TraderManager, st *store.Store) *ContextBuilder {
return &ContextBuilder{
traderManager: tm,
store: st,
}
}
// BuildContext builds current trading context
func (cb *ContextBuilder) BuildContext() *TradingContext {
ctx := &TradingContext{
MarketPrices: make(map[string]float64),
PriceChanges24h: make(map[string]float64),
UpdatedAt: time.Now(),
}
// Get all traders
allTraders := cb.traderManager.GetAllTraders()
for id, trader := range allTraders {
summary := TraderSummary{
ID: id,
Name: trader.GetName(),
Exchange: trader.GetExchange(),
IsRunning: true, // If in map, it's running
}
// Get account info
if accountInfo, err := trader.GetAccountInfo(); err == nil {
if equity, ok := accountInfo["total_equity"].(float64); ok {
summary.Equity = equity
ctx.TotalEquity += equity
}
if available, ok := accountInfo["available_balance"].(float64); ok {
ctx.AvailableBalance += available
}
}
// Get positions
if positions, err := trader.GetPositions(); err == nil {
summary.PositionCount = len(positions)
for _, pos := range positions {
posSummary := cb.parsePosition(pos, id, trader.GetName())
if posSummary != nil {
ctx.Positions = append(ctx.Positions, *posSummary)
ctx.UnrealizedPnL += posSummary.UnrealizedPnL
// Track market prices
ctx.MarketPrices[posSummary.Symbol] = posSummary.MarkPrice
// Check for alerts
cb.checkPositionAlerts(ctx, posSummary)
}
}
}
ctx.ActiveTraders = append(ctx.ActiveTraders, summary)
}
return ctx
}
// parsePosition parses position data into summary
func (cb *ContextBuilder) parsePosition(pos map[string]interface{}, traderID, traderName string) *PositionSummary {
summary := &PositionSummary{
TraderID: traderID,
TraderName: traderName,
}
if symbol, ok := pos["symbol"].(string); ok {
summary.Symbol = symbol
}
if side, ok := pos["side"].(string); ok {
summary.Side = strings.ToLower(side)
}
if size, ok := pos["size"].(float64); ok {
summary.Size = size
}
if entry, ok := pos["entry_price"].(float64); ok {
summary.EntryPrice = entry
}
if mark, ok := pos["mark_price"].(float64); ok {
summary.MarkPrice = mark
}
if pnl, ok := pos["unrealized_pnl"].(float64); ok {
summary.UnrealizedPnL = pnl
}
if lev, ok := pos["leverage"].(int); ok {
summary.Leverage = lev
}
if liq, ok := pos["liquidation_price"].(float64); ok {
summary.LiquidationPrice = liq
}
// Calculate PnL percent
if summary.EntryPrice > 0 && summary.Size > 0 {
if summary.Side == "long" {
summary.PnLPercent = ((summary.MarkPrice - summary.EntryPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
} else {
summary.PnLPercent = ((summary.EntryPrice - summary.MarkPrice) / summary.EntryPrice) * 100 * float64(summary.Leverage)
}
}
return summary
}
// checkPositionAlerts checks for position-related alerts
func (cb *ContextBuilder) checkPositionAlerts(ctx *TradingContext, pos *PositionSummary) {
// Liquidation risk alert
if pos.LiquidationPrice > 0 && pos.MarkPrice > 0 {
var distancePercent float64
if pos.Side == "long" {
distancePercent = ((pos.MarkPrice - pos.LiquidationPrice) / pos.MarkPrice) * 100
} else {
distancePercent = ((pos.LiquidationPrice - pos.MarkPrice) / pos.MarkPrice) * 100
}
if distancePercent < 5 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "danger",
Type: "liquidation_risk",
Message: fmt.Sprintf("⚠️ %s %s仓位距离强平仅 %.1f%%", pos.Symbol, pos.Side, distancePercent),
})
} else if distancePercent < 10 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "warning",
Type: "liquidation_risk",
Message: fmt.Sprintf("⚡ %s %s仓位距离强平 %.1f%%,注意风险", pos.Symbol, pos.Side, distancePercent),
})
}
}
// Large loss alert
if pos.PnLPercent < -20 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "danger",
Type: "large_loss",
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%,考虑止损", pos.Symbol, pos.Side, pos.PnLPercent),
})
} else if pos.PnLPercent < -10 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "warning",
Type: "large_loss",
Message: fmt.Sprintf("📉 %s %s仓位亏损 %.1f%%", pos.Symbol, pos.Side, pos.PnLPercent),
})
}
// Large profit - consider taking profit
if pos.PnLPercent > 50 {
ctx.Alerts = append(ctx.Alerts, Alert{
Level: "info",
Type: "large_profit",
Message: fmt.Sprintf("📈 %s %s仓位盈利 %.1f%%,考虑部分止盈", pos.Symbol, pos.Side, pos.PnLPercent),
})
}
}
// FormatContextForPrompt formats context as text for AI prompt injection
func (ctx *TradingContext) FormatContextForPrompt() string {
var sb strings.Builder
sb.WriteString("\n\n---\n## 📊 当前交易状态 (实时)\n\n")
// Portfolio summary
sb.WriteString(fmt.Sprintf("**总权益:** $%.2f | **可用余额:** $%.2f | **未实现盈亏:** $%.2f\n\n",
ctx.TotalEquity, ctx.AvailableBalance, ctx.UnrealizedPnL))
// Alerts (high priority)
if len(ctx.Alerts) > 0 {
sb.WriteString("### ⚠️ 警报\n")
for _, alert := range ctx.Alerts {
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
}
sb.WriteString("\n")
}
// Active positions
if len(ctx.Positions) > 0 {
sb.WriteString("### 📈 持仓\n")
sb.WriteString("| 交易对 | 方向 | 数量 | 入场价 | 现价 | 盈亏 | 盈亏% | 杠杆 | 交易员 |\n")
sb.WriteString("|--------|------|------|--------|------|------|-------|------|--------|\n")
for _, pos := range ctx.Positions {
pnlEmoji := "🟢"
if pos.UnrealizedPnL < 0 {
pnlEmoji = "🔴"
}
sb.WriteString(fmt.Sprintf("| %s | %s | %.4f | %.2f | %.2f | %s$%.2f | %.1f%% | %dx | %s |\n",
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.MarkPrice,
pnlEmoji, pos.UnrealizedPnL, pos.PnLPercent, pos.Leverage, pos.TraderName))
}
sb.WriteString("\n")
} else {
sb.WriteString("### 📈 持仓\n无持仓\n\n")
}
// Active traders
if len(ctx.ActiveTraders) > 0 {
sb.WriteString("### 🤖 运行中的交易员\n")
for _, t := range ctx.ActiveTraders {
status := "✅ 运行中"
if !t.IsRunning {
status = "❌ 已停止"
}
sb.WriteString(fmt.Sprintf("- **%s** (%s) %s | 权益: $%.2f | 持仓: %d\n",
t.Name, t.Exchange, status, t.Equity, t.PositionCount))
}
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("*数据更新时间: %s*\n---\n", ctx.UpdatedAt.Format("2006-01-02 15:04:05")))
return sb.String()
}
// GetTopSymbols returns symbols with positions for market data queries
func (ctx *TradingContext) GetTopSymbols() []string {
symbolSet := make(map[string]bool)
for _, pos := range ctx.Positions {
symbolSet[pos.Symbol] = true
}
// Always include major pairs
symbolSet["BTCUSDT"] = true
symbolSet["ETHUSDT"] = true
symbols := make([]string, 0, len(symbolSet))
for s := range symbolSet {
symbols = append(symbols, s)
}
return symbols
}
// EnrichWithMarketData adds market data to context
// Note: Market prices are already populated from position data
func (cb *ContextBuilder) EnrichWithMarketData(ctx *TradingContext, symbols []string) {
// Market prices are populated from position mark prices
// Additional market data enrichment can be added here in the future
}

200
assistant/monitor.go Normal file
View File

@@ -0,0 +1,200 @@
package assistant
import (
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/store"
"sync"
"time"
)
// Monitor provides proactive monitoring and alerts
type Monitor struct {
traderManager *manager.TraderManager
store *store.Store
contextBuilder *ContextBuilder
// Alert callbacks
alertCallbacks []func(Alert)
callbackMu sync.RWMutex
// State
running bool
stopChan chan struct{}
interval time.Duration
// Last known state for change detection
lastPositions map[string]PositionSummary
lastAlerts map[string]time.Time // Prevent alert spam
mu sync.RWMutex
}
// NewMonitor creates a new trading monitor
func NewMonitor(tm *manager.TraderManager, st *store.Store) *Monitor {
return &Monitor{
traderManager: tm,
store: st,
contextBuilder: NewContextBuilder(tm, st),
stopChan: make(chan struct{}),
interval: 30 * time.Second, // Check every 30 seconds
lastPositions: make(map[string]PositionSummary),
lastAlerts: make(map[string]time.Time),
}
}
// OnAlert registers an alert callback
func (m *Monitor) OnAlert(callback func(Alert)) {
m.callbackMu.Lock()
defer m.callbackMu.Unlock()
m.alertCallbacks = append(m.alertCallbacks, callback)
}
// Start starts the monitor
func (m *Monitor) Start() {
m.mu.Lock()
if m.running {
m.mu.Unlock()
return
}
m.running = true
m.stopChan = make(chan struct{})
m.mu.Unlock()
logger.Info("🔍 Starting trading monitor...")
go m.monitorLoop()
}
// Stop stops the monitor
func (m *Monitor) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if !m.running {
return
}
m.running = false
close(m.stopChan)
logger.Info("🔍 Trading monitor stopped")
}
// monitorLoop is the main monitoring loop
func (m *Monitor) monitorLoop() {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
// Initial check
m.checkAndAlert()
for {
select {
case <-ticker.C:
m.checkAndAlert()
case <-m.stopChan:
return
}
}
}
// checkAndAlert checks positions and sends alerts
func (m *Monitor) checkAndAlert() {
ctx := m.contextBuilder.BuildContext()
// Process built-in alerts from context
for _, alert := range ctx.Alerts {
m.sendAlertIfNew(alert)
}
// Check for position changes
m.checkPositionChanges(ctx)
// Check for new large movements
m.checkMarketMovements(ctx)
}
// checkPositionChanges detects significant position changes
func (m *Monitor) checkPositionChanges(ctx *TradingContext) {
m.mu.Lock()
defer m.mu.Unlock()
currentPositions := make(map[string]PositionSummary)
for _, pos := range ctx.Positions {
key := fmt.Sprintf("%s_%s_%s", pos.TraderID, pos.Symbol, pos.Side)
currentPositions[key] = pos
// Check if this is a new position
if _, existed := m.lastPositions[key]; !existed {
m.sendAlert(Alert{
Level: "info",
Type: "new_position",
Message: fmt.Sprintf("📍 新开仓位: %s %s %.4f @ %.2f (%dx)",
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.Leverage),
})
}
}
// Check for closed positions
for key, oldPos := range m.lastPositions {
if _, exists := currentPositions[key]; !exists {
m.sendAlert(Alert{
Level: "info",
Type: "position_closed",
Message: fmt.Sprintf("📍 仓位已平: %s %s (入场价: %.2f)",
oldPos.Symbol, oldPos.Side, oldPos.EntryPrice),
})
}
}
m.lastPositions = currentPositions
}
// checkMarketMovements checks for significant market movements
func (m *Monitor) checkMarketMovements(ctx *TradingContext) {
// This could be expanded to check price movements
// For now, we rely on the context builder's alerts
}
// sendAlertIfNew sends an alert only if it's new (avoid spam)
func (m *Monitor) sendAlertIfNew(alert Alert) {
m.mu.Lock()
defer m.mu.Unlock()
key := fmt.Sprintf("%s_%s", alert.Type, alert.Message)
// Check if we sent this alert recently (within 5 minutes)
if lastSent, ok := m.lastAlerts[key]; ok {
if time.Since(lastSent) < 5*time.Minute {
return // Skip, already sent recently
}
}
m.lastAlerts[key] = time.Now()
m.sendAlert(alert)
}
// sendAlert sends alert to all registered callbacks
func (m *Monitor) sendAlert(alert Alert) {
m.callbackMu.RLock()
callbacks := make([]func(Alert), len(m.alertCallbacks))
copy(callbacks, m.alertCallbacks)
m.callbackMu.RUnlock()
for _, cb := range callbacks {
go cb(alert)
}
}
// GetCurrentContext returns the current trading context
func (m *Monitor) GetCurrentContext() *TradingContext {
return m.contextBuilder.BuildContext()
}
// SetInterval sets the monitoring interval
func (m *Monitor) SetInterval(d time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
m.interval = d
}

117
assistant/prompts.go Normal file
View File

@@ -0,0 +1,117 @@
package assistant
// DefaultTradingSystemPrompt returns the default system prompt for trading assistant
func DefaultTradingSystemPrompt() string {
return `# NOFX Trading Assistant
You are an expert AI trading assistant powered by NOFX - an advanced AI-powered trading system.
## Your Capabilities
1. **Account Management**
- Check balances across multiple exchanges
- View current positions and P&L
- Monitor portfolio performance
2. **Trading Operations**
- Execute trades (open/close positions)
- Manage stop-loss and take-profit orders
- Adjust leverage and margin settings
3. **AI Traders Management**
- Start/stop AI traders
- Monitor AI trader performance
- Configure trading strategies
4. **Strategy & Analysis**
- Create and modify trading strategies
- Initiate AI debate sessions for market analysis
- Backtest strategies on historical data
5. **Market Intelligence**
- Get real-time prices and market data
- Analyze market conditions
- Track open interest and funding rates
## Guidelines
1. **Safety First**: Always confirm with the user before executing trades or making significant changes
2. **Be Precise**: When dealing with numbers, be exact - trading involves real money
3. **Explain Reasoning**: Help users understand your analysis and recommendations
4. **Risk Awareness**: Always remind users about the risks involved in trading
5. **Proactive Monitoring**: Alert users to important position changes or market movements
## Response Style
- Be concise but thorough
- Use tables for data when appropriate
- Include relevant metrics (P&L, ROI, etc.)
- Provide actionable insights, not just data dumps
- Support both English and Chinese (respond in the user's language)
## Important Notes
- Never share API keys or sensitive credentials
- Always use proper position sizing based on user's risk tolerance
- Warn users about high-risk operations (high leverage, large positions)
Remember: You are a professional trading assistant. Users trust you with their trading operations. Be accurate, be helpful, and be responsible.`
}
// ChineseSystemPrompt returns Chinese version of the system prompt
func ChineseSystemPrompt() string {
return `# NOFX 交易助手
你是一个由 NOFX 驱动的专业 AI 交易助手 - 一个先进的 AI 驱动交易系统。
## 你的能力
1. **账户管理**
- 查询多交易所余额
- 查看当前持仓和盈亏
- 监控投资组合表现
2. **交易操作**
- 执行交易(开仓/平仓)
- 管理止损止盈订单
- 调整杠杆和保证金设置
3. **AI 交易员管理**
- 启动/停止 AI 交易员
- 监控 AI 交易员表现
- 配置交易策略
4. **策略与分析**
- 创建和修改交易策略
- 发起 AI 辩论会议进行市场分析
- 回测历史数据
5. **市场情报**
- 获取实时价格和市场数据
- 分析市场状况
- 跟踪持仓量和资金费率
## 行为准则
1. **安全第一**:执行交易或重大操作前,务必与用户确认
2. **精确无误**:涉及数字时必须精确 - 交易涉及真金白银
3. **解释逻辑**:帮助用户理解你的分析和建议
4. **风险意识**:始终提醒用户交易风险
5. **主动监控**:及时提醒用户重要的仓位变化或市场波动
## 回复风格
- 简洁但全面
- 适当使用表格展示数据
- 包含相关指标(盈亏、收益率等)
- 提供可操作的见解,而非单纯的数据罗列
- 支持中英文(根据用户使用的语言回复)
## 重要提示
- 永远不要分享 API 密钥或敏感凭证
- 根据用户的风险承受能力进行合理的仓位管理
- 对高风险操作(高杠杆、大仓位)发出警告
记住:你是专业的交易助手。用户将交易操作托付于你。准确、有用、负责。`
}

122
assistant/session.go Normal file
View File

@@ -0,0 +1,122 @@
package assistant
import (
"sync"
"time"
)
// Message represents a single message in conversation
type Message struct {
Role string `json:"role"` // "user", "assistant", "system", "tool"
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
// For tool messages
ToolName string `json:"tool_name,omitempty"`
ToolResult interface{} `json:"tool_result,omitempty"`
}
// Session represents a conversation session with memory
type Session struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// User info
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Platform string `json:"platform"` // "telegram", "web", etc.
// Conversation history
messages []Message
maxMessages int
mu sync.RWMutex
// Custom metadata
Metadata map[string]interface{} `json:"metadata"`
}
// NewSession creates a new conversation session
func NewSession(id string, maxMessages int) *Session {
return &Session{
ID: id,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
messages: make([]Message, 0),
maxMessages: maxMessages,
Metadata: make(map[string]interface{}),
}
}
// AddMessage adds a message to the session
func (s *Session) AddMessage(msg Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = append(s.messages, msg)
s.UpdatedAt = time.Now()
// Trim old messages if exceeding max
if len(s.messages) > s.maxMessages {
// Keep the most recent messages
s.messages = s.messages[len(s.messages)-s.maxMessages:]
}
}
// GetMessages returns a copy of all messages
func (s *Session) GetMessages() []Message {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Message, len(s.messages))
copy(result, s.messages)
return result
}
// GetRecentMessages returns the N most recent messages
func (s *Session) GetRecentMessages(n int) []Message {
s.mu.RLock()
defer s.mu.RUnlock()
if n >= len(s.messages) {
result := make([]Message, len(s.messages))
copy(result, s.messages)
return result
}
result := make([]Message, n)
copy(result, s.messages[len(s.messages)-n:])
return result
}
// Clear removes all messages from the session
func (s *Session) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.messages = make([]Message, 0)
s.UpdatedAt = time.Now()
}
// SetUserInfo sets user information
func (s *Session) SetUserInfo(userID, userName, platform string) {
s.mu.Lock()
defer s.mu.Unlock()
s.UserID = userID
s.UserName = userName
s.Platform = platform
}
// SetMetadata sets a metadata value
func (s *Session) SetMetadata(key string, value interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.Metadata[key] = value
}
// GetMetadata gets a metadata value
func (s *Session) GetMetadata(key string) (interface{}, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.Metadata[key]
return v, ok
}

216
assistant/smart_agent.go Normal file
View File

@@ -0,0 +1,216 @@
package assistant
import (
"context"
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/mcp"
"nofx/store"
"strings"
"time"
)
// SmartAgent is an enhanced AI agent with trading context awareness
type SmartAgent struct {
*Agent
contextBuilder *ContextBuilder
monitor *Monitor
// Auto-inject context into prompts
autoInjectContext bool
}
// NewSmartAgent creates a new smart trading agent
func NewSmartAgent(aiClient mcp.AIClient, config AgentConfig, tm *manager.TraderManager, st *store.Store) *SmartAgent {
baseAgent := NewAgent(aiClient, config)
baseAgent.SetSystemPrompt(SmartTradingPrompt())
contextBuilder := NewContextBuilder(tm, st)
monitor := NewMonitor(tm, st)
return &SmartAgent{
Agent: baseAgent,
contextBuilder: contextBuilder,
monitor: monitor,
autoInjectContext: true,
}
}
// SetAutoInjectContext enables/disables automatic context injection
func (sa *SmartAgent) SetAutoInjectContext(enabled bool) {
sa.autoInjectContext = enabled
}
// StartMonitor starts the background monitor
func (sa *SmartAgent) StartMonitor() {
sa.monitor.Start()
}
// StopMonitor stops the background monitor
func (sa *SmartAgent) StopMonitor() {
sa.monitor.Stop()
}
// OnAlert registers an alert callback
func (sa *SmartAgent) OnAlert(callback func(Alert)) {
sa.monitor.OnAlert(callback)
}
// Chat processes a message with smart context injection
func (sa *SmartAgent) Chat(ctx context.Context, sessionID string, userMessage string) (*AgentResponse, error) {
session := sa.GetSession(sessionID)
// Add user message to history
session.AddMessage(Message{
Role: "user",
Content: userMessage,
Timestamp: time.Now(),
})
// Build system prompt with tools
systemPrompt := sa.buildSmartSystemPrompt()
// Build conversation prompt with context injection
conversationPrompt := sa.buildSmartConversationPrompt(session, userMessage)
// Agent loop
var finalResponse string
toolCallCount := 0
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if toolCallCount >= sa.config.MaxToolCalls {
logger.Warnf("⚠️ Max tool calls reached (%d)", sa.config.MaxToolCalls)
break
}
response, err := sa.aiClient.CallWithMessages(systemPrompt, conversationPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
toolCalls, textResponse, err := sa.parseResponse(response)
if err != nil {
finalResponse = response
break
}
if len(toolCalls) == 0 {
finalResponse = textResponse
break
}
// Execute tool calls
toolResults := sa.executeToolCalls(ctx, toolCalls)
toolCallCount += len(toolCalls)
// Add results to conversation
conversationPrompt += fmt.Sprintf("\n\nAssistant called tools:\n%s\n\nTool results:\n%s\n\nBased on the tool results, provide a helpful response:",
formatToolCalls(toolCalls),
formatToolResults(toolResults))
if textResponse != "" {
finalResponse = textResponse
}
}
// Add response to history
session.AddMessage(Message{
Role: "assistant",
Content: finalResponse,
Timestamp: time.Now(),
})
return &AgentResponse{
Text: finalResponse,
SessionID: sessionID,
}, nil
}
// buildSmartSystemPrompt builds system prompt with tools
func (sa *SmartAgent) buildSmartSystemPrompt() string {
sa.toolsLock.RLock()
defer sa.toolsLock.RUnlock()
var toolDefs []string
for _, tool := range sa.tools {
toolDef := fmt.Sprintf(`- **%s**: %s
Parameters: %s`, tool.Name(), tool.Description(), tool.ParameterSchema())
toolDefs = append(toolDefs, toolDef)
}
toolsSection := ""
if len(toolDefs) > 0 {
toolsSection = fmt.Sprintf(`
## 可用工具
调用工具时,使用以下 JSON 格式:
{"tool_calls": [{"name": "工具名", "arguments": {"参数": "值"}}]}
收到工具结果后,用自然语言回复用户。
可用工具:
%s
`, strings.Join(toolDefs, "\n"))
}
return sa.systemPrompt + toolsSection
}
// buildSmartConversationPrompt builds conversation with context injection
func (sa *SmartAgent) buildSmartConversationPrompt(session *Session, currentMessage string) string {
var sb strings.Builder
// Inject current trading context if enabled
if sa.autoInjectContext {
tradingCtx := sa.contextBuilder.BuildContext()
sb.WriteString(tradingCtx.FormatContextForPrompt())
}
// Add conversation history
messages := session.GetMessages()
for _, msg := range messages {
sb.WriteString(fmt.Sprintf("\n%s: %s\n", strings.Title(msg.Role), msg.Content))
}
return sb.String()
}
// QuickStatus returns a quick status summary
func (sa *SmartAgent) QuickStatus() string {
ctx := sa.contextBuilder.BuildContext()
var sb strings.Builder
sb.WriteString("📊 **交易状态概览**\n\n")
sb.WriteString(fmt.Sprintf("💰 总权益: $%.2f\n", ctx.TotalEquity))
sb.WriteString(fmt.Sprintf("💵 可用余额: $%.2f\n", ctx.AvailableBalance))
if ctx.UnrealizedPnL >= 0 {
sb.WriteString(fmt.Sprintf("📈 未实现盈亏: 🟢 +$%.2f\n", ctx.UnrealizedPnL))
} else {
sb.WriteString(fmt.Sprintf("📉 未实现盈亏: 🔴 $%.2f\n", ctx.UnrealizedPnL))
}
sb.WriteString(fmt.Sprintf("📍 持仓数: %d\n", len(ctx.Positions)))
sb.WriteString(fmt.Sprintf("🤖 运行交易员: %d\n", len(ctx.ActiveTraders)))
if len(ctx.Alerts) > 0 {
sb.WriteString("\n⚠ **警报**\n")
for _, alert := range ctx.Alerts {
sb.WriteString(fmt.Sprintf("- %s\n", alert.Message))
}
}
return sb.String()
}
// GetTradingContext returns current trading context
func (sa *SmartAgent) GetTradingContext() *TradingContext {
return sa.contextBuilder.BuildContext()
}

115
assistant/smart_prompts.go Normal file
View File

@@ -0,0 +1,115 @@
package assistant
import "fmt"
// SmartTradingPrompt returns an enhanced system prompt with trading intelligence
func SmartTradingPrompt() string {
return `# 🧠 NOFX 智能交易助手
你是一个专业的 AI 交易助手,具备以下能力:
## 核心能力
### 1. 智能分析
- 分析用户意图,理解交易需求
- 在执行交易前,主动评估风险
- 结合市场数据给出建议
### 2. 主动提醒
- 发现持仓风险时主动警告
- 大额亏损时建议止损
- 接近强平时紧急提醒
### 3. 专业建议
- 根据仓位情况建议操作
- 评估杠杆和仓位大小是否合理
- 提供入场/出场时机建议
## 交易原则
1. **安全第一**:任何交易操作前必须确认,高风险操作要多次确认
2. **风险控制**
- 单笔交易不超过总资金的 10%
- 杠杆建议BTC/ETH ≤10x山寨币 ≤5x
- 发现强平风险立即警告
3. **理性决策**:不鼓励情绪化交易,亏损时建议冷静
## 回复风格
- 简洁专业,像交易员一样说话
- 数据说话,给出具体数字
- 风险提示放在显眼位置
- 支持中英文,根据用户语言回复
## 工具使用策略
当用户问到持仓、余额时:
1. 先调用 list_traders 获取交易员列表
2. 对运行中的交易员调用 get_balance 和 get_positions
3. 汇总数据后清晰展示
当用户想交易时:
1. 先获取当前持仓和余额
2. 评估这笔交易的风险
3. 明确告知风险后请求确认
4. 确认后执行交易
当用户问市场行情时:
1. 获取相关币种价格
2. 结合持仓情况分析
3. 给出操作建议(但声明不构成投资建议)
## 重要:响应格式
- 持仓展示用表格
- 重要警告用 ⚠️ 标注
- 盈利用 🟢,亏损用 🔴
- 操作建议用列表
记住:你的目标是帮助用户更好地管理交易,而不是鼓励频繁交易。稳健盈利比追求高收益更重要。`
}
// RiskAssessmentPrompt returns a prompt for risk assessment before trades
func RiskAssessmentPrompt(action, symbol string, quantity, leverage float64, currentBalance, currentPositions string) string {
return fmt.Sprintf(`## 交易风险评估
请评估以下交易的风险:
**操作**: %s %s
**数量**: %.4f
**杠杆**: %.0fx
**当前账户状态**:
%s
**当前持仓**:
%s
请分析:
1. 这笔交易是否合理?
2. 仓位大小是否过大?
3. 杠杆是否过高?
4. 有什么潜在风险?
5. 你的建议是什么?
如果风险过高,请明确警告用户。`, action, symbol, quantity, leverage, currentBalance, currentPositions)
}
// MarketAnalysisPrompt returns a prompt for market analysis
func MarketAnalysisPrompt(symbol string, priceData, positionData string) string {
return fmt.Sprintf(`## %s 市场分析
**价格数据**:
%s
**相关持仓**:
%s
请分析:
1. 当前价格趋势
2. 关键支撑/阻力位
3. 持仓建议(继续持有/加仓/减仓/平仓)
4. 风险提示
注:这是基于有限数据的分析,不构成投资建议。`, symbol, priceData, positionData)
}

View File

@@ -0,0 +1,382 @@
// Package assistant - Intelligent Strategy Builder
// Allows users to create powerful, flexible trading strategies through natural language
package assistant
import (
"fmt"
"nofx/store"
"strings"
"time"
"github.com/google/uuid"
)
// StrategyType defines the type of trading strategy
type StrategyType string
const (
StrategyTypeAI StrategyType = "ai" // AI decides everything
StrategyTypeTrend StrategyType = "trend" // Trend following
StrategyTypeMeanRevert StrategyType = "mean_revert" // Mean reversion
StrategyTypeGrid StrategyType = "grid" // Grid trading
StrategyTypeDCA StrategyType = "dca" // Dollar cost averaging
StrategyTypeBreakout StrategyType = "breakout" // Breakout trading
StrategyTypeArbitrage StrategyType = "arbitrage" // Cross-exchange arbitrage
StrategyTypeCustom StrategyType = "custom" // Custom rules
)
// SmartStrategy represents a user-defined trading strategy
type SmartStrategy struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type StrategyType `json:"type"`
// Trading pairs
Symbols []string `json:"symbols"` // e.g., ["BTCUSDT", "ETHUSDT"]
SymbolMode string `json:"symbol_mode"` // "static", "ai_select", "top_volume", "top_oi"
MaxSymbols int `json:"max_symbols"` // Max symbols to trade simultaneously
// Entry conditions
EntryRules []Rule `json:"entry_rules"`
EntryMode string `json:"entry_mode"` // "any" (OR) or "all" (AND)
// Exit conditions
ExitRules []Rule `json:"exit_rules"`
TakeProfit *float64 `json:"take_profit"` // TP percentage
StopLoss *float64 `json:"stop_loss"` // SL percentage
TrailingStop *float64 `json:"trailing_stop"` // Trailing stop percentage
// Position sizing
PositionSize PositionSizeConfig `json:"position_size"`
MaxPositions int `json:"max_positions"` // Max concurrent positions
MaxPerSymbol int `json:"max_per_symbol"` // Max positions per symbol
// Risk management
RiskConfig RiskConfig `json:"risk_config"`
// Leverage settings
LeverageConfig LeverageConfig `json:"leverage_config"`
// Time settings
TimeConfig TimeConfig `json:"time_config"`
// AI enhancement
AIConfig AIStrategyConfig `json:"ai_config"`
// Metadata
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
IsActive bool `json:"is_active"`
Performance *StrategyPerformance `json:"performance,omitempty"`
}
// Rule represents a trading rule/condition
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "indicator", "price", "time", "volume", "ai", "custom"
Indicator string `json:"indicator"` // e.g., "RSI", "MACD", "EMA"
Condition string `json:"condition"` // e.g., "crosses_above", "greater_than", "less_than"
Value interface{} `json:"value"` // The value to compare against
Timeframe string `json:"timeframe"` // e.g., "1h", "4h", "1d"
Weight float64 `json:"weight"` // Weight for scoring (0-1)
Description string `json:"description"` // Human readable description
}
// PositionSizeConfig defines how to size positions
type PositionSizeConfig struct {
Mode string `json:"mode"` // "fixed", "percent", "risk_based", "kelly"
FixedAmount float64 `json:"fixed_amount"` // Fixed USDT amount
PercentOfEquity float64 `json:"percent_of_equity"` // Percentage of total equity
RiskPerTrade float64 `json:"risk_per_trade"` // Max risk per trade (%)
MaxSingleTrade float64 `json:"max_single_trade"` // Max single trade size (USDT)
}
// RiskConfig defines risk management rules
type RiskConfig struct {
MaxDrawdown float64 `json:"max_drawdown"` // Max drawdown before stopping (%)
MaxDailyLoss float64 `json:"max_daily_loss"` // Max daily loss (%)
MaxOpenRisk float64 `json:"max_open_risk"` // Max total open risk (%)
CooldownAfterLoss int `json:"cooldown_after_loss"` // Minutes to wait after a loss
RequireConfirmation bool `json:"require_confirmation"` // Require user confirmation for trades
EmergencyStopLoss float64 `json:"emergency_stop_loss"` // Emergency SL for all positions (%)
}
// LeverageConfig defines leverage settings
type LeverageConfig struct {
Mode string `json:"mode"` // "fixed", "dynamic", "per_symbol"
DefaultLeverage int `json:"default_leverage"`
MaxLeverage int `json:"max_leverage"`
PerSymbol map[string]int `json:"per_symbol"` // Symbol-specific leverage
PerVolatility []VolatilityLever `json:"per_volatility"` // Volatility-based leverage
}
// VolatilityLever defines leverage based on volatility
type VolatilityLever struct {
MaxVolatility float64 `json:"max_volatility"` // ATR percentage threshold
Leverage int `json:"leverage"`
}
// TimeConfig defines time-based settings
type TimeConfig struct {
TradingHours []TimeRange `json:"trading_hours"` // When to trade
AvoidNews bool `json:"avoid_news"` // Avoid major news events
AvoidWeekends bool `json:"avoid_weekends"`
MinHoldTime int `json:"min_hold_time"` // Minimum hold time (minutes)
MaxHoldTime int `json:"max_hold_time"` // Maximum hold time (minutes)
ScanInterval int `json:"scan_interval"` // How often to scan (minutes)
}
// TimeRange represents a time range
type TimeRange struct {
Start string `json:"start"` // "09:00"
End string `json:"end"` // "17:00"
TZ string `json:"tz"` // Timezone
}
// AIStrategyConfig defines AI-specific settings
type AIStrategyConfig struct {
Enabled bool `json:"enabled"`
Model string `json:"model"` // AI model to use
ConfidenceThreshold float64 `json:"confidence_threshold"` // Min confidence to act
UseMarketSentiment bool `json:"use_market_sentiment"`
UseTechnicalAnalysis bool `json:"use_technical_analysis"`
UseOnChainData bool `json:"use_onchain_data"`
CustomPrompt string `json:"custom_prompt"` // Custom instructions for AI
Personality string `json:"personality"` // "aggressive", "conservative", "balanced"
}
// StrategyPerformance tracks strategy performance
type StrategyPerformance struct {
TotalTrades int `json:"total_trades"`
WinningTrades int `json:"winning_trades"`
LosingTrades int `json:"losing_trades"`
WinRate float64 `json:"win_rate"`
TotalPnL float64 `json:"total_pnl"`
MaxDrawdown float64 `json:"max_drawdown"`
SharpeRatio float64 `json:"sharpe_ratio"`
ProfitFactor float64 `json:"profit_factor"`
AvgWin float64 `json:"avg_win"`
AvgLoss float64 `json:"avg_loss"`
LastUpdated time.Time `json:"last_updated"`
}
// StrategyBuilder helps users create strategies through conversation
type StrategyBuilder struct {
store *store.Store
}
// NewStrategyBuilder creates a new strategy builder
func NewStrategyBuilder(st *store.Store) *StrategyBuilder {
return &StrategyBuilder{store: st}
}
// CreateFromNaturalLanguage creates a strategy from natural language description
func (sb *StrategyBuilder) CreateFromNaturalLanguage(description string, userID string) (*SmartStrategy, error) {
// This would typically call an AI to parse the description
// For now, we create a basic template
strategy := &SmartStrategy{
ID: uuid.New().String()[:8],
Name: "Custom Strategy",
Description: description,
Type: StrategyTypeAI,
SymbolMode: "ai_select",
MaxSymbols: 5,
EntryMode: "all",
MaxPositions: 5,
MaxPerSymbol: 1,
PositionSize: PositionSizeConfig{
Mode: "percent",
PercentOfEquity: 5,
MaxSingleTrade: 1000,
},
RiskConfig: RiskConfig{
MaxDrawdown: 20,
MaxDailyLoss: 5,
MaxOpenRisk: 10,
CooldownAfterLoss: 30,
RequireConfirmation: true,
EmergencyStopLoss: 30,
},
LeverageConfig: LeverageConfig{
Mode: "dynamic",
DefaultLeverage: 3,
MaxLeverage: 10,
},
TimeConfig: TimeConfig{
ScanInterval: 5,
AvoidWeekends: false,
},
AIConfig: AIStrategyConfig{
Enabled: true,
ConfidenceThreshold: 0.7,
UseMarketSentiment: true,
UseTechnicalAnalysis: true,
Personality: "balanced",
CustomPrompt: description,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedBy: userID,
IsActive: false,
}
return strategy, nil
}
// CreateGridStrategy creates a grid trading strategy
func (sb *StrategyBuilder) CreateGridStrategy(symbol string, lowerPrice, upperPrice float64, gridCount int, amountPerGrid float64) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: fmt.Sprintf("Grid %s", symbol),
Description: fmt.Sprintf("Grid trading %s from %.2f to %.2f with %d grids", symbol, lowerPrice, upperPrice, gridCount),
Type: StrategyTypeGrid,
Symbols: []string{symbol},
SymbolMode: "static",
MaxPositions: gridCount,
PositionSize: PositionSizeConfig{
Mode: "fixed",
FixedAmount: amountPerGrid,
},
EntryRules: []Rule{
{
ID: "grid_entry",
Type: "price",
Condition: "grid_level",
Value: map[string]interface{}{
"lower_price": lowerPrice,
"upper_price": upperPrice,
"grid_count": gridCount,
},
},
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// CreateDCAStrategy creates a DCA strategy
func (sb *StrategyBuilder) CreateDCAStrategy(symbol string, intervalMinutes int, amountPerBuy float64, maxBuys int) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: fmt.Sprintf("DCA %s", symbol),
Description: fmt.Sprintf("DCA into %s every %d minutes, $%.2f per buy, max %d buys", symbol, intervalMinutes, amountPerBuy, maxBuys),
Type: StrategyTypeDCA,
Symbols: []string{symbol},
SymbolMode: "static",
MaxPositions: maxBuys,
PositionSize: PositionSizeConfig{
Mode: "fixed",
FixedAmount: amountPerBuy,
},
TimeConfig: TimeConfig{
ScanInterval: intervalMinutes,
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// CreateTrendStrategy creates a trend following strategy
func (sb *StrategyBuilder) CreateTrendStrategy(symbols []string, emaFast, emaSlow int, leverage int) *SmartStrategy {
return &SmartStrategy{
ID: uuid.New().String()[:8],
Name: "Trend Following",
Description: fmt.Sprintf("EMA %d/%d crossover strategy", emaFast, emaSlow),
Type: StrategyTypeTrend,
Symbols: symbols,
SymbolMode: "static",
EntryMode: "all",
EntryRules: []Rule{
{
ID: "ema_cross",
Name: "EMA Crossover",
Type: "indicator",
Indicator: "EMA",
Condition: "crosses_above",
Value: map[string]int{
"fast_period": emaFast,
"slow_period": emaSlow,
},
Timeframe: "1h",
Weight: 1.0,
},
},
ExitRules: []Rule{
{
ID: "ema_cross_exit",
Name: "EMA Crossover Exit",
Type: "indicator",
Indicator: "EMA",
Condition: "crosses_below",
Value: map[string]int{
"fast_period": emaFast,
"slow_period": emaSlow,
},
Timeframe: "1h",
},
},
LeverageConfig: LeverageConfig{
Mode: "fixed",
DefaultLeverage: leverage,
},
CreatedAt: time.Now(),
IsActive: false,
}
}
// StrategyToPrompt converts a strategy to an AI prompt
func StrategyToPrompt(s *SmartStrategy) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("# 策略: %s\n\n", s.Name))
sb.WriteString(fmt.Sprintf("**描述**: %s\n", s.Description))
sb.WriteString(fmt.Sprintf("**类型**: %s\n\n", s.Type))
// Trading pairs
if len(s.Symbols) > 0 {
sb.WriteString(fmt.Sprintf("**交易对**: %s\n", strings.Join(s.Symbols, ", ")))
} else {
sb.WriteString(fmt.Sprintf("**选币模式**: %s (最多 %d 个)\n", s.SymbolMode, s.MaxSymbols))
}
// Entry rules
if len(s.EntryRules) > 0 {
sb.WriteString("\n## 入场规则\n")
for _, rule := range s.EntryRules {
sb.WriteString(fmt.Sprintf("- %s: %s %s %v\n", rule.Name, rule.Indicator, rule.Condition, rule.Value))
}
}
// Exit rules
sb.WriteString("\n## 出场规则\n")
if s.TakeProfit != nil {
sb.WriteString(fmt.Sprintf("- 止盈: %.1f%%\n", *s.TakeProfit))
}
if s.StopLoss != nil {
sb.WriteString(fmt.Sprintf("- 止损: %.1f%%\n", *s.StopLoss))
}
if s.TrailingStop != nil {
sb.WriteString(fmt.Sprintf("- 移动止损: %.1f%%\n", *s.TrailingStop))
}
// Risk management
sb.WriteString("\n## 风险管理\n")
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n", s.RiskConfig.MaxDrawdown))
sb.WriteString(fmt.Sprintf("- 单日最大亏损: %.1f%%\n", s.RiskConfig.MaxDailyLoss))
sb.WriteString(fmt.Sprintf("- 最大持仓数: %d\n", s.MaxPositions))
// AI settings
if s.AIConfig.Enabled {
sb.WriteString("\n## AI 配置\n")
sb.WriteString(fmt.Sprintf("- 置信度阈值: %.0f%%\n", s.AIConfig.ConfidenceThreshold*100))
sb.WriteString(fmt.Sprintf("- 风格: %s\n", s.AIConfig.Personality))
if s.AIConfig.CustomPrompt != "" {
sb.WriteString(fmt.Sprintf("- 自定义指令: %s\n", s.AIConfig.CustomPrompt))
}
}
return sb.String()
}

548
assistant/strategy_tools.go Normal file
View File

@@ -0,0 +1,548 @@
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/store"
)
// StrategyTools provides strategy management tools for the AI agent
type StrategyTools struct {
store *store.Store
strategyBuilder *StrategyBuilder
strategies map[string]*SmartStrategy // In-memory strategy cache
}
// NewStrategyTools creates strategy tools
func NewStrategyTools(st *store.Store) *StrategyTools {
return &StrategyTools{
store: st,
strategyBuilder: NewStrategyBuilder(st),
strategies: make(map[string]*SmartStrategy),
}
}
// GetAllTools returns all strategy tools
func (st *StrategyTools) GetAllTools() []Tool {
return []Tool{
st.CreateStrategyTool(),
st.CreateGridStrategyTool(),
st.CreateDCAStrategyTool(),
st.CreateTrendStrategyTool(),
st.ListSmartStrategiesTool(),
st.GetStrategyDetailsTool(),
st.UpdateStrategyTool(),
st.ActivateStrategyTool(),
st.DeactivateStrategyTool(),
st.DeleteStrategyTool(),
st.GetStrategyTemplates(),
}
}
// CreateStrategyTool creates a strategy from natural language
func (st *StrategyTools) CreateStrategyTool() Tool {
return NewTool(
"create_strategy",
`Create a new trading strategy from natural language description.
Examples:
- "当RSI低于30时买入BTCRSI高于70时卖出"
- "每天定投100美元ETH"
- "BTC在5万到6万之间做网格交易"`,
`{
"name": "string (required) - Strategy name",
"description": "string (required) - Natural language description of the strategy",
"symbols": "array (optional) - Trading pairs, e.g., [\"BTCUSDT\", \"ETHUSDT\"]",
"take_profit": "number (optional) - Take profit percentage",
"stop_loss": "number (optional) - Stop loss percentage",
"leverage": "number (optional) - Leverage to use (default: 3)",
"max_positions": "number (optional) - Max concurrent positions (default: 5)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Name string `json:"name"`
Description string `json:"description"`
Symbols []string `json:"symbols"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
Leverage int `json:"leverage"`
MaxPositions int `json:"max_positions"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Description == "" {
return nil, fmt.Errorf("strategy description is required")
}
strategy, err := st.strategyBuilder.CreateFromNaturalLanguage(params.Description, "default")
if err != nil {
return nil, err
}
// Apply user customizations
if params.Name != "" {
strategy.Name = params.Name
}
if len(params.Symbols) > 0 {
strategy.Symbols = params.Symbols
strategy.SymbolMode = "static"
}
if params.TakeProfit != nil {
strategy.TakeProfit = params.TakeProfit
}
if params.StopLoss != nil {
strategy.StopLoss = params.StopLoss
}
if params.Leverage > 0 {
strategy.LeverageConfig.DefaultLeverage = params.Leverage
}
if params.MaxPositions > 0 {
strategy.MaxPositions = params.MaxPositions
}
// Store in memory
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("策略 '%s' (ID: %s) 创建成功!使用 activate_strategy 激活它。", strategy.Name, strategy.ID),
}, nil
},
)
}
// CreateGridStrategyTool creates a grid trading strategy
func (st *StrategyTools) CreateGridStrategyTool() Tool {
return NewTool(
"create_grid_strategy",
"Create a grid trading strategy. Grid trading places buy and sell orders at predetermined price levels.",
`{
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
"lower_price": "number (required) - Lower price bound",
"upper_price": "number (required) - Upper price bound",
"grid_count": "number (required) - Number of grids (10-100)",
"amount_per_grid": "number (required) - USDT amount per grid"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbol string `json:"symbol"`
LowerPrice float64 `json:"lower_price"`
UpperPrice float64 `json:"upper_price"`
GridCount int `json:"grid_count"`
AmountPerGrid float64 `json:"amount_per_grid"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.LowerPrice >= params.UpperPrice {
return nil, fmt.Errorf("lower_price must be less than upper_price")
}
if params.GridCount < 2 || params.GridCount > 100 {
return nil, fmt.Errorf("grid_count must be between 2 and 100")
}
strategy := st.strategyBuilder.CreateGridStrategy(
params.Symbol, params.LowerPrice, params.UpperPrice,
params.GridCount, params.AmountPerGrid,
)
st.strategies[strategy.ID] = strategy
gridSize := (params.UpperPrice - params.LowerPrice) / float64(params.GridCount)
totalInvestment := params.AmountPerGrid * float64(params.GridCount)
return map[string]interface{}{
"success": true,
"strategy": strategy,
"details": map[string]interface{}{
"grid_size": gridSize,
"total_investment": totalInvestment,
"profit_per_grid": (gridSize / params.LowerPrice) * 100,
},
"message": fmt.Sprintf("网格策略创建成功!\n价格区间: %.2f - %.2f\n网格数: %d\n每格间距: %.2f\n总投资: $%.2f",
params.LowerPrice, params.UpperPrice, params.GridCount, gridSize, totalInvestment),
}, nil
},
)
}
// CreateDCAStrategyTool creates a DCA strategy
func (st *StrategyTools) CreateDCAStrategyTool() Tool {
return NewTool(
"create_dca_strategy",
"Create a Dollar Cost Averaging (DCA) strategy. Automatically buy at regular intervals.",
`{
"symbol": "string (required) - Trading pair, e.g., BTCUSDT",
"interval_minutes": "number (required) - Buy interval in minutes (min: 5)",
"amount_per_buy": "number (required) - USDT amount per purchase",
"max_buys": "number (optional) - Maximum number of buys (default: unlimited)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbol string `json:"symbol"`
IntervalMinutes int `json:"interval_minutes"`
AmountPerBuy float64 `json:"amount_per_buy"`
MaxBuys int `json:"max_buys"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.IntervalMinutes < 5 {
return nil, fmt.Errorf("interval must be at least 5 minutes")
}
if params.MaxBuys == 0 {
params.MaxBuys = 1000 // Effectively unlimited
}
strategy := st.strategyBuilder.CreateDCAStrategy(
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys,
)
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("DCA策略创建成功\n币种: %s\n定投间隔: %d分钟\n每次金额: $%.2f\n最大次数: %d",
params.Symbol, params.IntervalMinutes, params.AmountPerBuy, params.MaxBuys),
}, nil
},
)
}
// CreateTrendStrategyTool creates a trend following strategy
func (st *StrategyTools) CreateTrendStrategyTool() Tool {
return NewTool(
"create_trend_strategy",
"Create a trend following strategy using EMA crossover.",
`{
"symbols": "array (required) - Trading pairs",
"ema_fast": "number (optional) - Fast EMA period (default: 9)",
"ema_slow": "number (optional) - Slow EMA period (default: 21)",
"leverage": "number (optional) - Leverage (default: 3)",
"take_profit": "number (optional) - Take profit %",
"stop_loss": "number (optional) - Stop loss %"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
Symbols []string `json:"symbols"`
EMAFast int `json:"ema_fast"`
EMASlow int `json:"ema_slow"`
Leverage int `json:"leverage"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if len(params.Symbols) == 0 {
params.Symbols = []string{"BTCUSDT", "ETHUSDT"}
}
if params.EMAFast == 0 {
params.EMAFast = 9
}
if params.EMASlow == 0 {
params.EMASlow = 21
}
if params.Leverage == 0 {
params.Leverage = 3
}
strategy := st.strategyBuilder.CreateTrendStrategy(
params.Symbols, params.EMAFast, params.EMASlow, params.Leverage,
)
strategy.TakeProfit = params.TakeProfit
strategy.StopLoss = params.StopLoss
st.strategies[strategy.ID] = strategy
return map[string]interface{}{
"success": true,
"strategy": strategy,
"message": fmt.Sprintf("趋势策略创建成功!\nEMA %d/%d 交叉\n交易对: %v\n杠杆: %dx",
params.EMAFast, params.EMASlow, params.Symbols, params.Leverage),
}, nil
},
)
}
// ListSmartStrategiesTool lists all smart strategies
func (st *StrategyTools) ListSmartStrategiesTool() Tool {
return NewTool(
"list_smart_strategies",
"List all smart strategies (both in-memory and saved).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var result []map[string]interface{}
for _, s := range st.strategies {
result = append(result, map[string]interface{}{
"id": s.ID,
"name": s.Name,
"type": s.Type,
"description": s.Description,
"is_active": s.IsActive,
"symbols": s.Symbols,
"created_at": s.CreatedAt,
})
}
// Also get strategies from store
if dbStrategies, err := st.store.Strategy().List("default"); err == nil {
for _, s := range dbStrategies {
result = append(result, map[string]interface{}{
"id": s.ID,
"name": s.Name,
"type": "db_strategy",
"description": s.Description,
"is_active": s.IsActive,
"source": "database",
})
}
}
if len(result) == 0 {
return map[string]interface{}{
"strategies": []interface{}{},
"message": "暂无策略。使用 create_strategy 创建一个新策略。",
}, nil
}
return map[string]interface{}{
"strategies": result,
"count": len(result),
}, nil
},
)
}
// GetStrategyDetailsTool gets detailed strategy info
func (st *StrategyTools) GetStrategyDetailsTool() Tool {
return NewTool(
"get_strategy_details",
"Get detailed information about a specific strategy.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if s, ok := st.strategies[params.StrategyID]; ok {
return map[string]interface{}{
"strategy": s,
"prompt_text": StrategyToPrompt(s),
}, nil
}
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
},
)
}
// UpdateStrategyTool updates a strategy
func (st *StrategyTools) UpdateStrategyTool() Tool {
return NewTool(
"update_strategy",
"Update an existing strategy's settings.",
`{
"strategy_id": "string (required)",
"name": "string (optional)",
"take_profit": "number (optional)",
"stop_loss": "number (optional)",
"leverage": "number (optional)",
"max_positions": "number (optional)",
"symbols": "array (optional)"
}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
Name string `json:"name"`
TakeProfit *float64 `json:"take_profit"`
StopLoss *float64 `json:"stop_loss"`
Leverage int `json:"leverage"`
MaxPositions int `json:"max_positions"`
Symbols []string `json:"symbols"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
if params.Name != "" {
s.Name = params.Name
}
if params.TakeProfit != nil {
s.TakeProfit = params.TakeProfit
}
if params.StopLoss != nil {
s.StopLoss = params.StopLoss
}
if params.Leverage > 0 {
s.LeverageConfig.DefaultLeverage = params.Leverage
}
if params.MaxPositions > 0 {
s.MaxPositions = params.MaxPositions
}
if len(params.Symbols) > 0 {
s.Symbols = params.Symbols
}
return map[string]interface{}{
"success": true,
"strategy": s,
"message": "策略已更新",
}, nil
},
)
}
// ActivateStrategyTool activates a strategy
func (st *StrategyTools) ActivateStrategyTool() Tool {
return NewTool(
"activate_strategy",
"Activate a strategy to start trading. ⚠️ This will start real trading!",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
s.IsActive = true
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("⚠️ 策略 '%s' 已激活!将开始真实交易。", s.Name),
"strategy": s,
}, nil
},
)
}
// DeactivateStrategyTool deactivates a strategy
func (st *StrategyTools) DeactivateStrategyTool() Tool {
return NewTool(
"deactivate_strategy",
"Deactivate a strategy to stop trading.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
s, ok := st.strategies[params.StrategyID]
if !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
s.IsActive = false
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("策略 '%s' 已停用", s.Name),
}, nil
},
)
}
// DeleteStrategyTool deletes a strategy
func (st *StrategyTools) DeleteStrategyTool() Tool {
return NewTool(
"delete_strategy",
"Delete a strategy permanently.",
`{"strategy_id": "string (required)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
StrategyID string `json:"strategy_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if _, ok := st.strategies[params.StrategyID]; !ok {
return nil, fmt.Errorf("strategy not found: %s", params.StrategyID)
}
delete(st.strategies, params.StrategyID)
return map[string]interface{}{
"success": true,
"message": "策略已删除",
}, nil
},
)
}
// GetStrategyTemplates returns available strategy templates
func (st *StrategyTools) GetStrategyTemplates() Tool {
return NewTool(
"get_strategy_templates",
"Get available strategy templates and examples.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
templates := []map[string]interface{}{
{
"name": "AI 智能交易",
"type": "ai",
"description": "让 AI 自主分析市场并决策,适合不想手动盯盘的用户",
"example": "create_strategy(name='AI智能', description='分析BTC和ETH的技术指标和市场情绪在有明确趋势时入场')",
},
{
"name": "网格交易",
"type": "grid",
"description": "在价格区间内自动低买高卖,适合震荡行情",
"example": "create_grid_strategy(symbol='BTCUSDT', lower_price=90000, upper_price=100000, grid_count=20, amount_per_grid=100)",
},
{
"name": "定投 DCA",
"type": "dca",
"description": "定期定额买入,摊薄成本,适合长期投资",
"example": "create_dca_strategy(symbol='ETHUSDT', interval_minutes=1440, amount_per_buy=50, max_buys=365)",
},
{
"name": "趋势跟踪",
"type": "trend",
"description": "跟随趋势EMA金叉买入死叉卖出",
"example": "create_trend_strategy(symbols=['BTCUSDT','ETHUSDT'], ema_fast=9, ema_slow=21, leverage=3)",
},
{
"name": "RSI 超买超卖",
"type": "custom",
"description": "RSI 低于 30 买入,高于 70 卖出",
"example": "create_strategy(name='RSI策略', description='当RSI14低于30时买入高于70时卖出止损10%')",
},
{
"name": "突破策略",
"type": "breakout",
"description": "价格突破关键位时入场",
"example": "create_strategy(name='突破策略', description='当价格突破20日最高点时做多突破20日最低点时做空')",
},
}
return map[string]interface{}{
"templates": templates,
"message": "以上是可用的策略模板,选择一个并告诉我你想怎么定制!",
}, nil
},
)
}

47
assistant/tool.go Normal file
View File

@@ -0,0 +1,47 @@
package assistant
import (
"context"
"encoding/json"
)
// Tool represents a callable tool that the AI agent can use
type Tool interface {
// Name returns the tool's unique identifier
Name() string
// Description returns a human-readable description for the AI
Description() string
// ParameterSchema returns JSON schema for the tool's parameters
ParameterSchema() string
// Execute runs the tool with the given arguments
Execute(ctx context.Context, args json.RawMessage) (interface{}, error)
}
// BaseTool provides common functionality for tools
type BaseTool struct {
ToolName string
ToolDescription string
ToolSchema string
ExecuteFunc func(ctx context.Context, args json.RawMessage) (interface{}, error)
}
func (t *BaseTool) Name() string { return t.ToolName }
func (t *BaseTool) Description() string { return t.ToolDescription }
func (t *BaseTool) ParameterSchema() string { return t.ToolSchema }
func (t *BaseTool) Execute(ctx context.Context, args json.RawMessage) (interface{}, error) {
return t.ExecuteFunc(ctx, args)
}
// NewTool creates a simple tool from a function
func NewTool(name, description, schema string, fn func(ctx context.Context, args json.RawMessage) (interface{}, error)) Tool {
return &BaseTool{
ToolName: name,
ToolDescription: description,
ToolSchema: schema,
ExecuteFunc: fn,
}
}

530
assistant/trading_tools.go Normal file
View File

@@ -0,0 +1,530 @@
package assistant
import (
"context"
"encoding/json"
"fmt"
"nofx/logger"
"nofx/manager"
"nofx/store"
)
// TradingTools provides all trading-related tools for the AI agent
type TradingTools struct {
traderManager *manager.TraderManager
store *store.Store
}
// NewTradingTools creates trading tools with access to NOFX core
func NewTradingTools(tm *manager.TraderManager, st *store.Store) *TradingTools {
return &TradingTools{
traderManager: tm,
store: st,
}
}
// GetAllTools returns all trading tools
func (t *TradingTools) GetAllTools() []Tool {
return []Tool{
t.GetBalanceTool(),
t.GetPositionsTool(),
t.ListTradersTool(),
t.GetTraderStatusTool(),
t.StartTraderTool(),
t.StopTraderTool(),
t.GetMarketPriceTool(),
t.OpenLongTool(),
t.OpenShortTool(),
t.ClosePositionTool(),
t.ListStrategiesTool(),
t.ListExchangesTool(),
t.ListAIModelsTool(),
}
}
// ==================== Query Tools ====================
// GetBalanceTool returns the get_balance tool
func (t *TradingTools) GetBalanceTool() Tool {
return NewTool(
"get_balance",
"Get account balance for a trader. Returns available balance, total equity, and margin info.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
balance, err := trader.GetAccountInfo()
if err != nil {
return nil, fmt.Errorf("failed to get balance: %w", err)
}
return balance, nil
},
)
}
// GetPositionsTool returns the get_positions tool
func (t *TradingTools) GetPositionsTool() Tool {
return NewTool(
"get_positions",
"Get all open positions for a trader. Returns symbol, side, size, entry price, unrealized P&L.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
positions, err := trader.GetPositions()
if err != nil {
return nil, fmt.Errorf("failed to get positions: %w", err)
}
return positions, nil
},
)
}
// ListTradersTool returns the list_traders tool
func (t *TradingTools) ListTradersTool() Tool {
return NewTool(
"list_traders",
"List all configured AI traders with their status (running/stopped), exchange, AI model, and performance.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
traders, err := t.store.Trader().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list traders: %w", err)
}
var result []map[string]interface{}
for _, tr := range traders {
traderInfo := map[string]interface{}{
"id": tr.ID,
"name": tr.Name,
"is_running": tr.IsRunning,
"ai_model_id": tr.AIModelID,
"exchange_id": tr.ExchangeID,
"strategy_id": tr.StrategyID,
"created_at": tr.CreatedAt,
}
// Try to get live status if trader is running
if liveTrader, err := t.traderManager.GetTrader(tr.ID); err == nil {
status := liveTrader.GetStatus()
traderInfo["live_status"] = status
}
result = append(result, traderInfo)
}
return result, nil
},
)
}
// GetTraderStatusTool returns detailed status of a specific trader
func (t *TradingTools) GetTraderStatusTool() Tool {
return NewTool(
"get_trader_status",
"Get detailed status of a specific trader including current positions, recent trades, and performance metrics.",
`{"trader_id": "string (required) - The trader ID to query"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
// Get trader config from store
traderConfig, err := t.store.Trader().GetByID(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
result := map[string]interface{}{
"id": traderConfig.ID,
"name": traderConfig.Name,
"is_running": traderConfig.IsRunning,
"ai_model_id": traderConfig.AIModelID,
"exchange_id": traderConfig.ExchangeID,
"strategy_id": traderConfig.StrategyID,
}
// If trader is running, get live data
trader, err := t.traderManager.GetTrader(params.TraderID)
if err == nil && trader != nil {
result["live_status"] = trader.GetStatus()
if balance, err := trader.GetAccountInfo(); err == nil {
result["balance"] = balance
}
if positions, err := trader.GetPositions(); err == nil {
result["positions"] = positions
}
}
return result, nil
},
)
}
// ==================== Control Tools ====================
// StartTraderTool starts an AI trader
func (t *TradingTools) StartTraderTool() Tool {
return NewTool(
"start_trader",
"Start an AI trader to begin automated trading. The trader will execute trades based on its configured strategy.",
`{"trader_id": "string (required) - The trader ID to start"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
// Check if already running
existingTrader, _ := t.traderManager.GetTrader(params.TraderID)
if existingTrader != nil {
status := existingTrader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
return nil, fmt.Errorf("trader is already running")
}
// Remove from memory to reload
t.traderManager.RemoveTrader(params.TraderID)
}
// Load and start trader
if err := t.traderManager.LoadUserTradersFromStore(t.store, "default"); err != nil {
return nil, fmt.Errorf("failed to load trader: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("failed to get trader after load: %w", err)
}
// Start the trader in a goroutine
go func() {
if err := trader.Run(); err != nil {
logger.Errorf("Trader %s error: %v", params.TraderID, err)
}
}()
// Update status in database
if err := t.store.Trader().UpdateStatus("default", params.TraderID, true); err != nil {
logger.Warnf("Failed to update trader status in DB: %v", err)
}
return map[string]interface{}{
"success": true,
"trader_id": params.TraderID,
"message": "Trader started successfully",
}, nil
},
)
}
// StopTraderTool stops an AI trader
func (t *TradingTools) StopTraderTool() Tool {
return NewTool(
"stop_trader",
"Stop an AI trader. This will halt automated trading but keep existing positions open.",
`{"trader_id": "string (required) - The trader ID to stop"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
trader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
// Check if running
status := trader.GetStatus()
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
return nil, fmt.Errorf("trader is already stopped")
}
// Stop the trader
trader.Stop()
// Update status in database
if err := t.store.Trader().UpdateStatus("default", params.TraderID, false); err != nil {
logger.Warnf("Failed to update trader status in DB: %v", err)
}
return map[string]interface{}{
"success": true,
"trader_id": params.TraderID,
"message": "Trader stopped successfully",
}, nil
},
)
}
// ==================== Trading Tools ====================
// GetMarketPriceTool gets current market price
func (t *TradingTools) GetMarketPriceTool() Tool {
return NewTool(
"get_market_price",
"Get current market price for a trading pair from a specific trader's exchange.",
`{"trader_id": "string (required)", "symbol": "string (required) - e.g., BTCUSDT"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
// Get the underlying trader interface
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
price, err := underlyingTrader.GetMarketPrice(params.Symbol)
if err != nil {
return nil, fmt.Errorf("failed to get price: %w", err)
}
return map[string]interface{}{
"symbol": params.Symbol,
"price": price,
}, nil
},
)
}
// OpenLongTool opens a long position
func (t *TradingTools) OpenLongTool() Tool {
return NewTool(
"open_long",
"Open a long (buy) position. WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Leverage == 0 {
params.Leverage = 1
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
result, err := underlyingTrader.OpenLong(params.Symbol, params.Quantity, params.Leverage)
if err != nil {
return nil, fmt.Errorf("failed to open long: %w", err)
}
return result, nil
},
)
}
// OpenShortTool opens a short position
func (t *TradingTools) OpenShortTool() Tool {
return NewTool(
"open_short",
"Open a short (sell) position. WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "quantity": "number (required)", "leverage": "number (optional, default 1)"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
if params.Leverage == 0 {
params.Leverage = 1
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
result, err := underlyingTrader.OpenShort(params.Symbol, params.Quantity, params.Leverage)
if err != nil {
return nil, fmt.Errorf("failed to open short: %w", err)
}
return result, nil
},
)
}
// ClosePositionTool closes a position
func (t *TradingTools) ClosePositionTool() Tool {
return NewTool(
"close_position",
"Close an existing position (long or short). WARNING: This will execute a real trade!",
`{"trader_id": "string (required)", "symbol": "string (required)", "side": "string (required) - 'long' or 'short'", "quantity": "number (optional) - leave empty to close all"}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
var params struct {
TraderID string `json:"trader_id"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Quantity float64 `json:"quantity"`
}
if err := json.Unmarshal(args, &params); err != nil {
return nil, fmt.Errorf("invalid arguments: %w", err)
}
autoTrader, err := t.traderManager.GetTrader(params.TraderID)
if err != nil {
return nil, fmt.Errorf("trader not found: %w", err)
}
underlyingTrader := autoTrader.GetUnderlyingTrader()
if underlyingTrader == nil {
return nil, fmt.Errorf("underlying trader not available")
}
var result map[string]interface{}
if params.Side == "long" {
result, err = underlyingTrader.CloseLong(params.Symbol, params.Quantity)
} else if params.Side == "short" {
result, err = underlyingTrader.CloseShort(params.Symbol, params.Quantity)
} else {
return nil, fmt.Errorf("invalid side: %s (must be 'long' or 'short')", params.Side)
}
if err != nil {
return nil, fmt.Errorf("failed to close position: %w", err)
}
return result, nil
},
)
}
// ==================== Config Tools ====================
// ListStrategiesTool lists all strategies
func (t *TradingTools) ListStrategiesTool() Tool {
return NewTool(
"list_strategies",
"List all trading strategies configured in the system.",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
strategies, err := t.store.Strategy().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list strategies: %w", err)
}
return strategies, nil
},
)
}
// ListExchangesTool lists all exchange configurations
func (t *TradingTools) ListExchangesTool() Tool {
return NewTool(
"list_exchanges",
"List all configured exchanges (without showing API keys).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
exchanges, err := t.store.Exchange().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list exchanges: %w", err)
}
// Remove sensitive data
var result []map[string]interface{}
for _, ex := range exchanges {
result = append(result, map[string]interface{}{
"id": ex.ID,
"name": ex.Name,
"exchange_type": ex.ExchangeType,
"type": ex.Type,
"enabled": ex.Enabled,
})
}
return result, nil
},
)
}
// ListAIModelsTool lists all AI model configurations
func (t *TradingTools) ListAIModelsTool() Tool {
return NewTool(
"list_ai_models",
"List all configured AI models (without showing API keys).",
`{}`,
func(ctx context.Context, args json.RawMessage) (interface{}, error) {
models, err := t.store.AIModel().List("default")
if err != nil {
return nil, fmt.Errorf("failed to list AI models: %w", err)
}
// Remove sensitive data
var result []map[string]interface{}
for _, m := range models {
result = append(result, map[string]interface{}{
"id": m.ID,
"name": m.Name,
"provider": m.Provider,
"custom_model": m.CustomModelName,
"enabled": m.Enabled,
})
}
return result, nil
},
)
}

View File

@@ -1,12 +1,15 @@
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"
)
@@ -22,6 +25,9 @@ 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)
@@ -81,6 +87,30 @@ 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{
@@ -117,3 +147,8 @@ 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,34 +71,6 @@ 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")
@@ -153,11 +125,6 @@ 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)

View File

@@ -15,8 +15,10 @@ 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
APIServerPort int
JWTSecret string
RegistrationEnabled bool
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
// Database configuration
DBType string // sqlite or postgres
@@ -42,13 +44,14 @@ 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",
@@ -68,6 +71,16 @@ 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,14 +97,6 @@ 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

@@ -1,50 +0,0 @@
# ⚠️ 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,7 +241,6 @@ 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,19 +44,6 @@ 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

@@ -1,126 +0,0 @@
# 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

@@ -1,120 +0,0 @@
# 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,30 +1,22 @@
<h1 align="center">NOFX — オープンソース AI トレーディング OS</h1>
# NOFX - AI トレーディングシステム
<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>
[![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)
**言語:** [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、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
@@ -63,8 +55,6 @@ 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,30 +1,22 @@
<h1 align="center">NOFX — 오픈소스 AI 트레이딩 OS</h1>
# NOFX - AI 트레이딩 시스템
<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>
[![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)
**언어:** [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, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
@@ -63,8 +55,6 @@ 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,30 +1,22 @@
<h1 align="center">NOFX — Open Source AI Торговая ОС</h1>
# NOFX - AI Торговая Система
<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>
[![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)
**Языки:** [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, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
@@ -63,8 +55,6 @@
| **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,30 +1,22 @@
<h1 align="center">NOFX — Open Source AI Торгова ОС</h1>
# NOFX - AI Торгова Система
<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>
[![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)
**Мови:** [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, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
@@ -63,8 +55,6 @@
| **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,30 +1,22 @@
<h1 align="center">NOFX Hệ Điều Hành Giao Dịch AI Mã Nguồn Mở</h1>
# NOFX - Hệ Thống Giao Dịch AI
<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>
[![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)
**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, Bitget, KuCoin, Gate, 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, 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
@@ -63,8 +55,6 @@ 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,21 +1,9 @@
<h1 align="center">NOFX — 开源 AI 交易操作系统</h1>
# NOFX - AI 交易系统
<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>
[![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)
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
@@ -28,10 +16,14 @@
---
## AI 驱动的加密货币交易平台
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
### 核心功能
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
@@ -75,8 +67,6 @@
| **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 (去中心化永续交易所)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

41
go.mod
View File

@@ -5,32 +5,34 @@ go 1.25.3
require (
github.com/adshao/go-binance/v2 v2.8.9
github.com/agiledragon/gomonkey/v2 v2.13.0
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
github.com/ethereum/go-ethereum v1.16.7
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
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
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.42.0
golang.org/x/net v0.43.0
gopkg.in/telebot.v3 v3.3.8
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.40.0
)
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
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -42,22 +44,17 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elastic/go-sysinfo v1.15.4 // indirect
github.com/elastic/go-windows v1.0.2 // indirect
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
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
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -68,20 +65,14 @@ 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
@@ -93,7 +84,6 @@ 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
@@ -103,27 +93,16 @@ 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
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect

877
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
package kernel
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -9,7 +8,6 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/security"
"nofx/store"
@@ -492,44 +490,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
// 空列表是正常情况,直接返回
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":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -564,28 +524,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
}
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 {
@@ -702,52 +640,6 @@ func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
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
// ============================================================================
@@ -1458,8 +1350,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasAI500 := false
hasOITop := false
hasOILow := false
hasHyperAll := false
hasHyperMain := false
for _, s := range sources {
switch s {
case "ai500":
@@ -1468,10 +1358,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasOITop = true
case "oi_low":
hasOILow = true
case "hyper_all":
hasHyperAll = true
case "hyper_main":
hasHyperMain = true
}
}
if hasAI500 && hasOITop {
@@ -1483,12 +1369,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
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] {
@@ -1500,10 +1380,6 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
}
}
return ""

View File

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

108
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"nofx/api"
"nofx/assistant"
"nofx/auth"
"nofx/backtest"
"nofx/config"
@@ -131,20 +132,57 @@ 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)
// Initialize and start Telegram bot (if configured)
var telegramBot *telegram.Bot
telegramConfig := telegram.LoadConfigFromEnv()
if telegramConfig.Token != "" {
logger.Info("🤖 Initializing Smart Trading Assistant...")
// Create AI client for the assistant
aiClient := createAssistantAIClient()
if aiClient == nil {
logger.Error("❌ No AI API key configured, Telegram bot disabled")
} else {
// Create Smart AI Agent with trading context awareness
agentConfig := assistant.DefaultAgentConfig()
smartAgent := assistant.NewSmartAgent(aiClient, agentConfig, traderManager, st)
// Register trading tools
tradingTools := assistant.NewTradingTools(traderManager, st)
smartAgent.RegisterTools(tradingTools.GetAllTools()...)
// Register strategy tools
strategyTools := assistant.NewStrategyTools(st)
smartAgent.RegisterTools(strategyTools.GetAllTools()...)
// Create and start Telegram bot
var err error
telegramBot, err = telegram.NewBot(telegramConfig, smartAgent.Agent)
if err != nil {
logger.Errorf("❌ Failed to create Telegram bot: %v", err)
} else {
// Start background monitor with alert forwarding to Telegram
smartAgent.OnAlert(func(alert assistant.Alert) {
telegramBot.BroadcastAlert(alert.Message)
})
smartAgent.StartMonitor()
go telegramBot.Start()
logger.Info("✅ Smart Trading Assistant started successfully")
logger.Info(" 📊 Real-time context injection: enabled")
logger.Info(" 🔍 Background monitoring: enabled")
logger.Info(" ⚠️ Proactive alerts: enabled")
}
}
} else {
logger.Info(" Telegram bot not configured (set TELEGRAM_BOT_TOKEN to enable)")
}
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
@@ -156,6 +194,11 @@ func main() {
<-quit
logger.Info("📴 Shutdown signal received, closing system...")
// Stop Telegram bot
if telegramBot != nil {
telegramBot.Stop()
}
// Stop all traders
traderManager.StopAll()
logger.Info("✅ System shut down safely")
@@ -171,6 +214,55 @@ func newSharedMCPClient() mcp.AIClient {
return mcp.NewDeepSeekClient()
}
// createAssistantAIClient creates an AI client for the Telegram assistant
// Supports multiple providers based on environment configuration
func createAssistantAIClient() mcp.AIClient {
// Try different providers in order of preference
// 1. DeepSeek (cost-effective, recommended)
if apiKey := os.Getenv("DEEPSEEK_API_KEY"); apiKey != "" {
client := mcp.NewDeepSeekClient()
customURL := os.Getenv("DEEPSEEK_API_URL")
customModel := os.Getenv("DEEPSEEK_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using DeepSeek AI")
return client
}
// 2. Claude
if apiKey := os.Getenv("CLAUDE_API_KEY"); apiKey != "" {
client := mcp.NewClaudeClient()
customURL := os.Getenv("CLAUDE_API_URL")
customModel := os.Getenv("CLAUDE_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Claude AI")
return client
}
// 3. OpenAI
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
client := mcp.NewOpenAIClient()
customURL := os.Getenv("OPENAI_API_URL")
customModel := os.Getenv("OPENAI_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using OpenAI")
return client
}
// 4. Qwen
if apiKey := os.Getenv("QWEN_API_KEY"); apiKey != "" {
client := mcp.NewQwenClient()
customURL := os.Getenv("QWEN_API_URL")
customModel := os.Getenv("QWEN_MODEL")
client.SetAPIKey(apiKey, customURL, customModel)
logger.Info("🧠 Assistant using Qwen AI")
return client
}
logger.Warn("⚠️ No AI API key configured for assistant")
return nil
}
// initInstallationID initializes the anonymous installation ID for experience improvement
// This ID is persisted in database and used for anonymous usage statistics
func initInstallationID(st *store.Store) {

View File

@@ -407,6 +407,7 @@ 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
@@ -663,11 +664,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",
@@ -689,17 +690,9 @@ 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
@@ -710,9 +703,6 @@ 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, exchange string, limit int) ([]Kline, error) {
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
// Map interval string to coinank enum
var coinankInterval coinank_enum.Interval
switch interval {
@@ -67,44 +67,13 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
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, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
if err != nil {
// 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)
}
return nil, fmt.Errorf("CoinAnk API error: %w", err)
}
// Convert coinank kline format to market.Kline format
@@ -165,13 +134,8 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
return klines, nil
}
// Get retrieves market data for the specified token (uses Binance data by default)
// Get retrieves market data for the specified token
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
@@ -180,21 +144,18 @@ func GetWithExchange(symbol, exchange 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 useHyperliquidAPI {
if isXyzAsset {
// 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 with exchange-specific data
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
// Use CoinAnk for regular crypto assets
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
}
}
@@ -205,15 +166,15 @@ func GetWithExchange(symbol, exchange string) (*Data, error) {
}
// Get 4-hour K-line data
if useHyperliquidAPI {
if isXyzAsset {
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", exchange, 100)
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
if err != nil {
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
}
}
@@ -329,8 +290,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
continue
}
} else {
// Use CoinAnk for regular crypto assets (default to Binance)
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
// Use CoinAnk for regular crypto assets
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
if err != nil {
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
continue
@@ -1107,11 +1068,6 @@ 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
@@ -1327,7 +1283,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
if IsXyzDexAsset(symbol) {
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
} else {
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
}
if err != nil {

View File

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

View File

@@ -1,345 +0,0 @@
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)
}

View File

@@ -1,277 +0,0 @@
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,19 +1,3 @@
// 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 (
@@ -25,67 +9,78 @@ import (
const (
ProviderClaude = "claude"
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
DefaultClaudeModel = "claude-opus-4-6"
DefaultClaudeModel = "claude-opus-4-5-20251101"
)
// 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 a ClaudeClient with default settings.
// NewClaudeClient creates Claude client (backward compatible)
func NewClaudeClient() AIClient {
return NewClaudeClientWithOptions()
}
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
baseClient := NewClient(append([]ClientOption{
// 1. Create Claude preset options
claudeOpts := []ClientOption{
WithProvider(ProviderClaude),
WithModel(DefaultClaudeModel),
WithBaseURL(DefaultClaudeBaseURL),
}, opts...)...).(*Client)
}
c := &ClaudeClient{Client: baseClient}
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
return c
// 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
}
// ── Hook overrides ────────────────────────────────────────────────────────────
// SetAPIKey stores credentials and optional custom endpoint / model.
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, 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 BaseURL: %s", customURL)
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
} else {
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
}
if customModel != "" {
c.Model = customModel
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
} else {
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
}
}
// 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")
// 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")
}
// buildUrl targets /messages instead of /chat/completions.
// buildUrl Claude uses /messages endpoint
func (c *ClaudeClient) buildUrl() string {
return fmt.Sprintf("%s/messages", c.BaseURL)
}
// buildMCPRequestBody builds the Anthropic wire format for the simple
// CallWithMessages path (no tool support).
// buildMCPRequestBody Claude has different request format
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
return map[string]any{
requestBody := map[string]any{
"model": c.Model,
"max_tokens": c.MaxTokens,
"system": systemPrompt,
@@ -93,175 +88,16 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
{"role": "user", "content": userPrompt},
},
}
return requestBody
}
// 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).
// parseMCPResponse Claude has different response format
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
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 {
var response struct {
Content []struct {
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"`
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
@@ -273,46 +109,36 @@ func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
} `json:"error"`
}
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 err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
}
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
if TokenUsageCallback != nil && total > 0 {
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 {
TokenUsageCallback(TokenUsage{
Provider: c.Provider,
Model: c.Model,
PromptTokens: raw.Usage.InputTokens,
CompletionTokens: raw.Usage.OutputTokens,
TotalTokens: total,
PromptTokens: response.Usage.InputTokens,
CompletionTokens: response.Usage.OutputTokens,
TotalTokens: totalTokens,
})
}
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),
},
})
// Find text content
for _, content := range response.Content {
if content.Type == "text" {
return content.Text, nil
}
}
return result, nil
return "", fmt.Errorf("no text content in Claude response")
}

View File

@@ -1,248 +0,0 @@
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)
}

View File

@@ -1,166 +0,0 @@
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,9 +1,7 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -234,21 +232,10 @@ 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"`
ToolCalls []ToolCall `json:"tool_calls"`
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Usage struct {
@@ -259,11 +246,11 @@ func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
return "", fmt.Errorf("failed to parse response: %w", err)
}
if len(result.Choices) == 0 {
return nil, fmt.Errorf("API returned empty response")
return "", fmt.Errorf("API returned empty response")
}
// Report token usage if callback is set
@@ -277,11 +264,7 @@ func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
})
}
msg := result.Choices[0].Message
return &LLMResponse{
Content: msg.Content,
ToolCalls: msg.ToolCalls,
}, nil
return result.Choices[0].Message.Content, nil
}
func (client *Client) buildUrl() string {
@@ -442,106 +425,50 @@ 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))
requestBody := client.hooks.buildRequestBodyFromRequest(req)
// Build request body (from Request object)
requestBody := client.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)
@@ -552,23 +479,13 @@ 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 — 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))
// Convert Message to API format
messages := make([]map[string]string, 0, len(req.Messages))
for _, msg := range req.Messages {
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)
messages = append(messages, map[string]string{
"role": msg.Role,
"content": msg.Content,
})
}
// Build basic request body
@@ -627,124 +544,3 @@ 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,7 +7,6 @@ import (
"time"
"nofx/logger"
"nofx/security"
)
// Config client configuration (centralized management of all configurations)
@@ -49,7 +48,7 @@ func DefaultConfig() *Config {
// Default dependencies (use global logger)
Logger: logger.NewMCPLogger(),
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
HTTPClient: &http.Client{Timeout: DefaultTimeout},
}
}

View File

@@ -10,52 +10,21 @@ 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)
// 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)
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
}
// 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.
// clientHooks internal hook interface (for subclass to override specific steps)
// These methods are only used inside the package to implement dynamic dispatch
type clientHooks interface {
// ── Simple CallWithMessages path ────────────────────────────────────────
call(systemPrompt, userPrompt string) (string, error)
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
// Hook methods that can be overridden by subclass
// ── Shared request plumbing ─────────────────────────────────────────────
call(systemPrompt, userPrompt string) (string, error)
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
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
}

View File

@@ -1,83 +0,0 @@
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)
}

View File

@@ -1,272 +0,0 @@
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.4"
DefaultOpenAIModel = "gpt-5.2"
)
type OpenAIClient struct {

View File

@@ -22,11 +22,7 @@ func WithLogger(logger Logger) ClientOption {
}
}
// 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.
// WithHTTPClient sets custom HTTP client
//
// Usage example:
// httpClient := &http.Client{Timeout: 60 * time.Second}
@@ -164,17 +160,3 @@ 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,34 +1,9 @@
package mcp
// Message represents a conversation message.
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
// and tool result messages (Role="tool", ToolCallID, Content).
// Message represents a conversation message
type Message struct {
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
Role string `json:"role"` // "system", "user", "assistant"
Content string `json:"content"` // Message content
}
// Tool represents a tool/function that AI can call

View File

@@ -1,219 +0,0 @@
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,18 +15,11 @@ 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 hashed static assets (js/css have content hashes in filenames)
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";

View File

@@ -1,223 +0,0 @@
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

@@ -105,8 +105,7 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
}
if len(availableCoins) == 0 {
// Empty list is normal - just return empty slice, not an error
return []string{}, nil
return nil, fmt.Errorf("no available coins")
}
// Sort by Score descending (bubble sort)
@@ -148,7 +147,10 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
}
}
// Empty list is normal - just return empty slice, not an error
if len(symbols) == 0 {
return nil, fmt.Errorf("no available coins")
}
return symbols, nil
}

183
start.sh
View File

@@ -1,7 +1,7 @@
#!/bin/bash
# ═══════════════════════════════════════════════════════════════
# NOFX AI Trading System - Docker Management Script
# NOFX AI Trading System - Docker Quick Start 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 not found. Please install Docker Compose first."
print_error "Docker Compose 未安装!请先安装 Docker Compose"
exit 1
fi
print_info "Using Docker Compose: $COMPOSE_CMD"
print_info "使用 Docker Compose 命令: $COMPOSE_CMD"
}
# ------------------------------------------------------------------------
@@ -56,12 +56,12 @@ detect_compose_cmd() {
# ------------------------------------------------------------------------
check_docker() {
if ! command -v docker &> /dev/null; then
print_error "Docker not found. Please install Docker: https://docs.docker.com/get-docker/"
print_error "Docker 未安装!请先安装 Docker: https://docs.docker.com/get-docker/"
exit 1
fi
detect_compose_cmd
print_success "Docker and Docker Compose are installed"
print_success "Docker Docker Compose 已安装"
}
# ------------------------------------------------------------------------
@@ -69,11 +69,11 @@ check_docker() {
# ------------------------------------------------------------------------
check_env() {
if [ ! -f ".env" ]; then
print_warning ".env not found, copying from template..."
print_warning ".env 不存在,从模板复制..."
cp .env.example .env
print_info ".env file created"
print_info "已创建 .env 文件"
fi
print_success "Environment file exists"
print_success "环境变量文件存在"
}
# ------------------------------------------------------------------------
@@ -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,23 +102,22 @@ is_env_configured() {
}
# ------------------------------------------------------------------------
# Helper: Set env var in .env file
# Helper: Generate and 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
}
@@ -127,46 +126,51 @@ set_env_var() {
# Validation: Encryption Keys in .env
# ------------------------------------------------------------------------
check_encryption() {
print_info "Checking encryption keys..."
print_info "检查加密密钥配置..."
local generated=false
# 检查并生成 JWT_SECRET
if ! is_env_configured "JWT_SECRET"; then
print_warning "JWT_SECRET not set, generating..."
print_warning "JWT_SECRET 未配置,正在生成..."
local jwt_secret=$(openssl rand -base64 32)
set_env_var "JWT_SECRET" "$jwt_secret"
print_success "JWT_SECRET generated"
print_success "JWT_SECRET 已生成"
generated=true
fi
# 检查并生成 DATA_ENCRYPTION_KEY
if ! is_env_configured "DATA_ENCRYPTION_KEY"; then
print_warning "DATA_ENCRYPTION_KEY not set, generating..."
print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..."
local data_key=$(openssl rand -base64 32)
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
print_success "DATA_ENCRYPTION_KEY generated"
print_success "DATA_ENCRYPTION_KEY 已生成"
generated=true
fi
# 检查并生成 RSA_PRIVATE_KEY
if ! is_env_configured "RSA_PRIVATE_KEY"; then
print_warning "RSA_PRIVATE_KEY not set, generating..."
print_warning "RSA_PRIVATE_KEY 未配置,正在生成..."
# 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n
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 generated"
print_success "RSA_PRIVATE_KEY 已生成"
generated=true
fi
if [ "$generated" = true ]; then
echo ""
print_success "Missing keys generated and saved to .env"
print_warning "Keep .env safe — do not commit it to version control"
print_success "所有缺失的密钥已自动生成并保存到 .env"
print_warning "请妥善保管 .env 文件,不要提交到版本控制系统"
echo ""
fi
print_success "Encryption keys OK"
print_success "加密密钥检查完成"
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
}
@@ -193,12 +197,13 @@ read_env_vars() {
# Validation: Database Directory (data/)
# ------------------------------------------------------------------------
check_database() {
# Ensure data directory exists
if [ ! -d "data" ]; then
print_warning "Data directory missing, creating data/..."
print_warning "数据目录不存在,创建 data/ 目录..."
install -m 700 -d data
print_success "data/ directory created"
print_success "已创建 data/ 目录"
else
print_success "Data directory exists"
print_success "数据目录存在"
fi
}
@@ -206,58 +211,47 @@ check_database() {
# Service Management: Start
# ------------------------------------------------------------------------
start() {
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ 🚀 NOFX AI Trading Bot — Startup ║${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
print_info "正在启动 NOFX AI Trading System..."
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
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 ""
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"
}
# ------------------------------------------------------------------------
# Service Management: Stop
# ------------------------------------------------------------------------
stop() {
print_info "Stopping services..."
print_info "正在停止服务..."
$COMPOSE_CMD stop
print_success "Services stopped"
print_success "服务已停止"
}
# ------------------------------------------------------------------------
# Service Management: Restart
# ------------------------------------------------------------------------
restart() {
print_info "Restarting services..."
print_info "正在重启服务..."
$COMPOSE_CMD restart
print_success "Services restarted"
print_success "服务已重启"
}
# ------------------------------------------------------------------------
@@ -277,25 +271,25 @@ logs() {
status() {
read_env_vars
print_info "Service status:"
print_info "服务状态:"
$COMPOSE_CMD ps
echo ""
print_info "Health check:"
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding"
print_info "健康检查:"
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应"
}
# ------------------------------------------------------------------------
# Maintenance: Clean (Destructive)
# ------------------------------------------------------------------------
clean() {
print_warning "This will delete all containers and data!"
read -p "Confirm? (yes/no): " confirm
print_warning "这将删除所有容器和数据!"
read -p "确认删除?(yes/no): " confirm
if [ "$confirm" == "yes" ]; then
print_info "Cleaning up..."
print_info "正在清理..."
$COMPOSE_CMD down -v
print_success "Cleanup complete"
print_success "清理完成"
else
print_info "Cancelled"
print_info "已取消"
fi
}
@@ -303,74 +297,77 @@ clean() {
# Maintenance: Update
# ------------------------------------------------------------------------
update() {
print_info "Updating..."
print_info "正在更新..."
git pull
$COMPOSE_CMD up -d --build
print_success "Update complete"
print_success "更新完成"
}
# ------------------------------------------------------------------------
# Command: Regenerate all keys (force)
# ------------------------------------------------------------------------
regenerate_keys() {
print_warning "This will regenerate ALL encryption keys!"
print_warning "Any existing encrypted data will become unreadable!"
print_warning "这将重新生成所有加密密钥!"
print_warning "如果已有加密数据,重新生成后将无法解密!"
echo ""
read -p "Confirm? (yes/no): " confirm
read -p "确认重新生成?(yes/no): " confirm
if [ "$confirm" != "yes" ]; then
print_info "Cancelled"
print_info "已取消"
return
fi
check_env
print_info "Generating new keys..."
print_info "正在生成新的密钥..."
# 生成 JWT_SECRET
local jwt_secret=$(openssl rand -base64 32)
set_env_var "JWT_SECRET" "$jwt_secret"
print_success "JWT_SECRET generated"
print_success "JWT_SECRET 已生成"
# 生成 DATA_ENCRYPTION_KEY
local data_key=$(openssl rand -base64 32)
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
print_success "DATA_ENCRYPTION_KEY generated"
print_success "DATA_ENCRYPTION_KEY 已生成"
# 生成 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 generated"
print_success "RSA_PRIVATE_KEY 已生成"
chmod 600 .env 2>/dev/null || true
echo ""
print_success "All keys regenerated and saved to .env"
print_warning "Keep .env safe"
print_success "所有密钥已重新生成并保存到 .env"
print_warning "请妥善保管 .env 文件"
}
# ------------------------------------------------------------------------
# Help: Usage Information
# ------------------------------------------------------------------------
show_help() {
echo "NOFX AI Trading System - Docker Management Script"
echo "NOFX AI Trading System - Docker 管理脚本"
echo ""
echo "Usage: ./start.sh [command] [options]"
echo "用法: ./start.sh [command] [options]"
echo ""
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 " start [--build] 启动服务(可选:重新构建)"
echo " stop 停止服务"
echo " restart 重启服务"
echo " logs [service] 查看日志(可选:指定服务名 backend/frontend"
echo " status 查看服务状态"
echo " clean 清理所有容器和数据"
echo " update 更新代码并重启"
echo " regenerate-keys 重新生成所有加密密钥(慎用)"
echo " help 显示此帮助信息"
echo ""
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 " ./start.sh start --build # 构建并启动"
echo " ./start.sh logs backend # 查看后端日志"
echo " ./start.sh status # 查看状态"
echo ""
echo "First time:"
echo " Just run ./start.sh — missing keys are generated automatically"
echo "首次使用:"
echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成"
}
# ------------------------------------------------------------------------
@@ -411,7 +408,7 @@ main() {
show_help
;;
*)
print_error "Unknown command: $1"
print_error "未知命令: $1"
show_help
exit 1
;;

View File

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

View File

@@ -17,28 +17,27 @@ 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"`
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"`
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"`
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" }
@@ -174,8 +173,6 @@ 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"
}
@@ -184,8 +181,7 @@ 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 string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String()
@@ -211,7 +207,6 @@ 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),
@@ -229,21 +224,19 @@ 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 string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
hyperliquidWalletAddr, 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,
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
"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,
"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
@@ -314,8 +307,7 @@ 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, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}

View File

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

View File

@@ -3,63 +3,12 @@ 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"`
@@ -207,8 +156,7 @@ 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
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
newEntryPrice = math.Round(newEntryPrice*100) / 100
newFee := pos.Fee + addFee
nowMs := time.Now().UTC().UnixMilli()
@@ -239,8 +187,7 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
var newExitPrice float64
if newClosedQty > 0 {
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
newExitPrice = math.Round(newExitPrice*100) / 100
}
nowMs := time.Now().UTC().UnixMilli()

View File

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

View File

@@ -18,18 +18,17 @@ 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
grid *GridStore
telegramConfig TelegramConfigStore
user *UserStore
aiModel *AIModelStore
exchange *ExchangeStore
trader *TraderStore
decision *DecisionStore
backtest *BacktestStore
position *PositionStore
strategy *StrategyStore
equity *EquityStore
order *OrderStore
grid *GridStore
mu sync.RWMutex
}
@@ -161,9 +160,6 @@ func (s *Store) initTables() error {
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
}
@@ -297,16 +293,6 @@ func (s *Store) Grid() *GridStore {
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

@@ -81,10 +81,6 @@ type GridStrategyConfig struct {
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
// Use maker-only orders for lower fees
UseMakerOnly bool `json:"use_maker_only"`
// Enable automatic grid direction adjustment based on box breakouts
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
}
// PromptSectionsConfig editable sections of System Prompt
@@ -119,12 +115,6 @@ type CoinSourceConfig struct {
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
}

View File

@@ -1,164 +0,0 @@
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

@@ -1,6 +1,8 @@
package store
import (
"crypto/rand"
"encoding/base32"
"time"
"gorm.io/gorm"
@@ -16,12 +18,24 @@ 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}
@@ -40,6 +54,9 @@ 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
@@ -97,11 +114,9 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
return userIDs, err
}
// 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
// UpdateOTPVerified updates OTP verification status
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
}
// UpdatePassword updates password
@@ -123,5 +138,7 @@ func (s *UserStore) EnsureAdmin() error {
ID: "admin",
Email: "admin@localhost",
PasswordHash: "",
OTPSecret: "",
OTPVerified: true,
})
}

View File

@@ -1,285 +0,0 @@
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

@@ -1,439 +0,0 @@
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)
}
}

View File

@@ -1,88 +0,0 @@
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)
}

View File

@@ -1,79 +0,0 @@
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
}

View File

@@ -1,97 +0,0 @@
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)
}

View File

@@ -1,479 +1,490 @@
// Package telegram provides Telegram bot integration for NOFX trading assistant
package telegram
import (
"nofx/api"
"nofx/config"
"context"
"fmt"
"nofx/assistant"
"nofx/logger"
"nofx/mcp"
"nofx/store"
"nofx/telegram/agent"
"os"
"strconv"
"strings"
"sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
tele "gopkg.in/telebot.v3"
)
// 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
}
// Bot represents the Telegram bot for NOFX
type Bot struct {
bot *tele.Bot
agent *assistant.Agent
config BotConfig
stopped := runBot(token, cfg, st)
if !stopped {
return
}
// Allowed users (for security)
allowedUsers map[int64]bool
allowedUsersLock sync.RWMutex
select {
case <-reloadCh:
logger.Info("Reloading Telegram bot with new token...")
}
// Rate limiting
rateLimiter *RateLimiter
}
// BotConfig holds bot configuration
type BotConfig struct {
Token string `json:"token"`
// Polling or webhook mode
UseWebhook bool `json:"use_webhook"`
WebhookURL string `json:"webhook_url"`
WebhookPort int `json:"webhook_port"`
// Security
AllowedUserIDs []int64 `json:"allowed_user_ids"` // Empty = allow all
AdminUserIDs []int64 `json:"admin_user_ids"`
// Rate limiting
MaxMessagesPerMinute int `json:"max_messages_per_minute"`
// Language
DefaultLanguage string `json:"default_language"` // "en" or "zh"
}
// DefaultBotConfig returns default configuration
func DefaultBotConfig() BotConfig {
return BotConfig{
MaxMessagesPerMinute: 30,
DefaultLanguage: "zh",
}
}
// 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
// NewBot creates a new Telegram bot
func NewBot(config BotConfig, agent *assistant.Agent) (*Bot, error) {
if config.Token == "" {
return nil, fmt.Errorf("telegram bot token is required")
}
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)
settings := tele.Settings{
Token: config.Token,
Poller: &tele.LongPoller{Timeout: 30 * time.Second},
}
teleBot, err := tele.NewBot(settings)
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
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
// botUserID / botToken / agents are resolved lazily and refresh when user registers.
var (
botUserID string
botUserEmail string
botToken string
agents *agent.Manager
bot := &Bot{
bot: teleBot,
agent: agent,
config: config,
allowedUsers: make(map[int64]bool),
rateLimiter: NewRateLimiter(config.MaxMessagesPerMinute),
}
// Initialize allowed users
for _, uid := range config.AllowedUserIDs {
bot.allowedUsers[uid] = true
}
// Register handlers
bot.registerHandlers()
return bot, nil
}
// Start starts the bot
func (b *Bot) Start() {
logger.Info("🤖 Starting Telegram bot...")
b.bot.Start()
}
// Stop stops the bot
func (b *Bot) Stop() {
logger.Info("🤖 Stopping Telegram bot...")
b.bot.Stop()
}
// registerHandlers sets up all message handlers
func (b *Bot) registerHandlers() {
// Middleware for access control and rate limiting
b.bot.Use(b.accessControlMiddleware)
b.bot.Use(b.rateLimitMiddleware)
// Command handlers
b.bot.Handle("/start", b.handleStart)
b.bot.Handle("/help", b.handleHelp)
b.bot.Handle("/status", b.handleStatus)
b.bot.Handle("/balance", b.handleBalance)
b.bot.Handle("/positions", b.handlePositions)
b.bot.Handle("/traders", b.handleTraders)
b.bot.Handle("/clear", b.handleClear)
// Handle all text messages (send to AI agent)
b.bot.Handle(tele.OnText, b.handleText)
// Handle callbacks (for inline keyboards)
b.bot.Handle(tele.OnCallback, b.handleCallback)
logger.Info("✅ Telegram handlers registered")
}
// accessControlMiddleware checks if user is allowed
func (b *Bot) accessControlMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
userID := c.Sender().ID
// If allowlist is empty, allow all
if len(b.config.AllowedUserIDs) == 0 {
return next(c)
}
b.allowedUsersLock.RLock()
allowed := b.allowedUsers[userID]
b.allowedUsersLock.RUnlock()
if !allowed {
logger.Warnf("⚠️ Unauthorized access attempt from user %d", userID)
return c.Send("⛔ Sorry, you are not authorized to use this bot.\n\n抱歉您没有使用此机器人的权限。")
}
return next(c)
}
}
// rateLimitMiddleware implements rate limiting
func (b *Bot) rateLimitMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
userID := c.Sender().ID
if !b.rateLimiter.Allow(userID) {
return c.Send("⏳ Please slow down. Too many messages.\n\n请稍等消息发送过于频繁。")
}
return next(c)
}
}
// ==================== Command Handlers ====================
func (b *Bot) handleStart(c tele.Context) error {
welcome := `🚀 *Welcome to NOFX Trading Assistant!*
I'm your AI-powered trading assistant. I can help you:
📊 *Monitor* - Check balances, positions, and market prices
🤖 *Manage* - Start/stop AI traders, configure strategies
💹 *Trade* - Execute trades (with confirmation)
📈 *Analyze* - Market analysis and AI debates
*Commands:*
/help - Show all commands
/status - System status
/balance - Account balances
/positions - Current positions
/traders - List AI traders
/clear - Clear conversation history
Or just chat with me in natural language!
---
🚀 *欢迎使用 NOFX 交易助手!*
我是你的 AI 交易助手,可以帮你:
📊 *监控* - 查看余额、持仓、行情
🤖 *管理* - 启停 AI 交易员、配置策略
💹 *交易* - 执行交易(需确认)
📈 *分析* - 市场分析和 AI 辩论
直接用自然语言和我对话即可!`
return c.Send(welcome, tele.ModeMarkdown)
}
func (b *Bot) handleHelp(c tele.Context) error {
help := `📖 *NOFX Trading Assistant Help*
*Commands:*
• /start - Welcome message
• /help - This help message
• /status - System overview
• /balance - Show all balances
• /positions - Show all positions
• /traders - List AI traders
• /clear - Clear conversation history
*Natural Language Examples:*
• "查看我的余额"
• "BTC 现在多少钱"
• "启动交易员 xxx"
• "帮我平掉 ETH 的多单"
• "我的持仓盈亏怎么样"
• "列出所有策略"
*Tips:*
• I'll always confirm before executing trades
• Use specific trader names/IDs for operations
• Ask me anything about your trading!`
return c.Send(help, tele.ModeMarkdown)
}
func (b *Bot) handleStatus(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Please give me a brief system status: list all traders and their status, show total positions count.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get status. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleBalance(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Show me all account balances from all running traders.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get balances. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handlePositions(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "Show me all current positions from all running traders with P&L.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to get positions. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleTraders(c tele.Context) error {
ctx := context.Background()
sessionID := b.getSessionID(c)
response, err := b.agent.Chat(ctx, sessionID, "List all configured AI traders with their status, exchange, and AI model.")
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Failed to list traders. Please try again.")
}
return c.Send(response.Text, tele.ModeMarkdown)
}
func (b *Bot) handleClear(c tele.Context) error {
sessionID := b.getSessionID(c)
session := b.agent.GetSession(sessionID)
session.Clear()
return c.Send("🧹 Conversation history cleared.\n\n对话历史已清除。")
}
// ==================== Message Handler ====================
func (b *Bot) handleText(c tele.Context) error {
text := strings.TrimSpace(c.Text())
if text == "" {
return nil
}
// Show typing indicator
_ = c.Notify(tele.Typing)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
sessionID := b.getSessionID(c)
// Set user info in session
session := b.agent.GetSession(sessionID)
session.SetUserInfo(
strconv.FormatInt(c.Sender().ID, 10),
c.Sender().Username,
"telegram",
)
resolveBotUser := func() bool {
users, err := st.User().GetAll()
if err != nil || len(users) == 0 {
return false
logger.Infof("💬 [%s] %s: %s", sessionID, c.Sender().Username, text)
response, err := b.agent.Chat(ctx, sessionID, text)
if err != nil {
logger.Errorf("Agent error: %v", err)
return c.Send("❌ Sorry, something went wrong. Please try again.\n\n抱歉出现了问题请重试。")
}
logger.Infof("🤖 [%s] Response: %s", sessionID, truncate(response.Text, 100))
// Send response (split if too long)
return b.sendLongMessage(c, response.Text)
}
// handleCallback handles inline keyboard callbacks
func (b *Bot) handleCallback(c tele.Context) error {
data := c.Callback().Data
// Parse callback data (format: "action:param1:param2")
parts := strings.Split(data, ":")
if len(parts) == 0 {
return c.Respond()
}
action := parts[0]
switch action {
case "confirm_trade":
if len(parts) >= 2 {
// Execute the confirmed trade
return b.executeConfirmedTrade(c, parts[1:])
}
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)
case "cancel_trade":
_ = c.Respond(&tele.CallbackResponse{Text: "Trade cancelled / 交易已取消"})
return c.Edit("❌ Trade cancelled.\n\n交易已取消。")
}
return c.Respond()
}
// ==================== Helpers ====================
func (b *Bot) getSessionID(c tele.Context) string {
return fmt.Sprintf("tg_%d", c.Chat().ID)
}
func (b *Bot) sendLongMessage(c tele.Context, text string) error {
// Telegram message limit is 4096 characters
const maxLen = 4000
if len(text) <= maxLen {
return c.Send(text, tele.ModeMarkdown)
}
// Split into chunks
for len(text) > 0 {
chunk := text
if len(chunk) > maxLen {
// Try to split at newline
idx := strings.LastIndex(text[:maxLen], "\n")
if idx > 0 {
chunk = text[:idx]
text = text[idx+1:]
} else {
chunk = text[:maxLen]
text = text[maxLen:]
}
} 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
text = ""
}
// ── /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
if err := c.Send(chunk, tele.ModeMarkdown); err != nil {
// Try without markdown if it fails
if err := c.Send(chunk); err != nil {
return err
}
}
}
// 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 (b *Bot) executeConfirmedTrade(c tele.Context, params []string) error {
// TODO: Implement trade execution from callback
_ = c.Respond(&tele.CallbackResponse{Text: "Executing trade..."})
return c.Edit("✅ Trade executed.\n\n交易已执行。")
}
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()
// AddAllowedUser adds a user to the allowlist
func (b *Bot) AddAllowedUser(userID int64) {
b.allowedUsersLock.Lock()
defer b.allowedUsersLock.Unlock()
b.allowedUsers[userID] = true
}
// BroadcastAlert sends an alert to all admin users
func (b *Bot) BroadcastAlert(message string) {
for _, adminID := range b.config.AdminUserIDs {
chat := &tele.Chat{ID: adminID}
_, err := b.bot.Send(chat, "🚨 "+message)
if err != nil {
logger.Errorf("Failed to send alert to admin %d: %v", adminID, err)
}
}
// If no admins configured, send to allowed users
if len(b.config.AdminUserIDs) == 0 {
for userID := range b.allowedUsers {
chat := &tele.Chat{ID: userID}
_, _ = b.bot.Send(chat, "🚨 "+message)
}
}
}
// ── Status message ────────────────────────────────────────────────────────────
// RemoveAllowedUser removes a user from the allowlist
func (b *Bot) RemoveAllowedUser(userID int64) {
b.allowedUsersLock.Lock()
defer b.allowedUsersLock.Unlock()
delete(b.allowedUsers, userID)
}
// 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
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
hasExchange := false
if exchanges, err := st.Exchange().List(userID); err == nil {
for _, e := range exchanges {
if e.Enabled {
hasExchange = true
break
}
// ==================== Rate Limiter ====================
// RateLimiter implements per-user rate limiting
type RateLimiter struct {
maxPerMinute int
users map[int64][]time.Time
mu sync.Mutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(maxPerMinute int) *RateLimiter {
return &RateLimiter{
maxPerMinute: maxPerMinute,
users: make(map[int64][]time.Time),
}
}
// Allow checks if a user is allowed to send a message
func (r *RateLimiter) Allow(userID int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
// Get user's recent messages
timestamps := r.users[userID]
// Filter out old timestamps
var recent []time.Time
for _, t := range timestamps {
if t.After(cutoff) {
recent = append(recent, t)
}
}
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."
// Check if under limit
if len(recent) >= r.maxPerMinute {
return false
}
// All configured — show ready state.
if lang == "zh" {
return `✅ *NOFX 就绪,开始交易吧!*
// Add current timestamp
recent = append(recent, now)
r.users[userID] = recent
直接告诉我你想做什么:
📊 "查看我的持仓"
💰 "账户余额多少"
🤖 "帮我创建 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`
return true
}

60
telegram/config.go Normal file
View File

@@ -0,0 +1,60 @@
package telegram
import (
"os"
"strconv"
"strings"
)
// LoadConfigFromEnv loads Telegram bot configuration from environment variables
func LoadConfigFromEnv() BotConfig {
config := DefaultBotConfig()
// Bot token (required)
config.Token = os.Getenv("TELEGRAM_BOT_TOKEN")
// Webhook settings
if webhook := os.Getenv("TELEGRAM_WEBHOOK_URL"); webhook != "" {
config.UseWebhook = true
config.WebhookURL = webhook
}
if port := os.Getenv("TELEGRAM_WEBHOOK_PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
config.WebhookPort = p
}
}
// Allowed users (comma-separated list of user IDs)
if allowedStr := os.Getenv("TELEGRAM_ALLOWED_USERS"); allowedStr != "" {
for _, idStr := range strings.Split(allowedStr, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
config.AllowedUserIDs = append(config.AllowedUserIDs, id)
}
}
}
// Admin users
if adminStr := os.Getenv("TELEGRAM_ADMIN_USERS"); adminStr != "" {
for _, idStr := range strings.Split(adminStr, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
config.AdminUserIDs = append(config.AdminUserIDs, id)
}
}
}
// Rate limiting
if rateStr := os.Getenv("TELEGRAM_RATE_LIMIT"); rateStr != "" {
if rate, err := strconv.Atoi(rateStr); err == nil {
config.MaxMessagesPerMinute = rate
}
}
// Language
if lang := os.Getenv("TELEGRAM_LANGUAGE"); lang != "" {
config.DefaultLanguage = lang
}
return config
}

View File

@@ -1,105 +0,0 @@
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 aster
package trader
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package aster
package trader
import (
"context"
@@ -23,7 +23,6 @@ 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
@@ -1296,14 +1295,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) ([]types.ClosedPnLRecord, error) {
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
trades, err := t.GetTrades(startTime, limit)
if err != nil {
return nil, err
}
// Filter only closing trades (realizedPnl != 0)
var records []types.ClosedPnLRecord
var records []ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue
@@ -1331,7 +1330,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Clos
}
}
records = append(records, types.ClosedPnLRecord{
records = append(records, ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1367,7 +1366,7 @@ type AsterTradeRecord struct {
}
// GetTrades retrieves trade history from Aster
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
if limit <= 0 {
limit = 500
}
@@ -1382,24 +1381,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRe
body, err := t.request("GET", "/fapi/v3/userTrades", params)
if err != nil {
logger.Infof("⚠️ Aster userTrades API error: %v", err)
return []types.TradeRecord{}, nil
return []TradeRecord{}, nil
}
var asterTrades []AsterTradeRecord
if err := json.Unmarshal(body, &asterTrades); err != nil {
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
return []types.TradeRecord{}, nil
return []TradeRecord{}, nil
}
// Convert to unified TradeRecord format
var result []types.TradeRecord
var result []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 := types.TradeRecord{
trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: at.Side,
@@ -1417,7 +1416,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRe
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
params := map[string]interface{}{
"symbol": symbol,
}
@@ -1443,13 +1442,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
return nil, fmt.Errorf("failed to parse open orders: %w", err)
}
var result []types.OpenOrder
var result []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{
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: order.Side,
@@ -1467,7 +1466,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
}
// PlaceLimitOrder places a limit order for grid trading
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format price and quantity to correct precision
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
if err != nil {
@@ -1533,7 +1532,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
clientOrderID = cid
}
return &types.LimitOrderResult{
return &LimitOrderResult{
OrderID: orderID,
ClientID: clientOrderID,
Symbol: req.Symbol,

View File

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

View File

@@ -4,22 +4,12 @@ import (
"encoding/json"
"fmt"
"math"
"nofx/experience"
"nofx/kernel"
"nofx/experience"
"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"
@@ -33,7 +23,7 @@ type AutoTraderConfig struct {
AIModel string // AI model: "qwen" or "deepseek"
// Trading platform selection
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
ExchangeID string // Exchange account UUID (for multi-account support)
// Binance API configuration
@@ -45,33 +35,19 @@ 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
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
HyperliquidPrivateKey string
HyperliquidWalletAddr string
HyperliquidTestnet bool
// Aster configuration
AsterUser string // Aster main wallet address
@@ -127,9 +103,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
@@ -201,26 +177,6 @@ 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
@@ -268,31 +224,25 @@ 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 = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
case "bybit":
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
case "okx":
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
case "bitget":
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
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)
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
}
case "aster":
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
}
@@ -304,7 +254,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
}
// Lighter only supports mainnet (testnet disabled)
trader, err = lighter.NewLighterTraderV2(
trader, err = NewLighterTraderV2(
config.LighterWalletAddr,
config.LighterAPIKeyPrivateKey,
config.LighterAPIKeyIndex,
@@ -314,9 +264,6 @@ 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)
}
@@ -416,7 +363,7 @@ func (at *AutoTrader) Run() error {
// Start Lighter order sync if using Lighter exchange
if at.exchange == "lighter" {
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
if lighterTrader, ok := at.trader.(*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)
}
@@ -424,7 +371,7 @@ func (at *AutoTrader) Run() error {
// Start Hyperliquid order sync if using Hyperliquid exchange
if at.exchange == "hyperliquid" {
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
if hyperliquidTrader, ok := at.trader.(*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)
}
@@ -432,7 +379,7 @@ func (at *AutoTrader) Run() error {
// Start Bybit order sync if using Bybit exchange
if at.exchange == "bybit" {
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
if bybitTrader, ok := at.trader.(*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)
}
@@ -440,7 +387,7 @@ func (at *AutoTrader) Run() error {
// Start OKX order sync if using OKX exchange
if at.exchange == "okx" {
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
if okxTrader, ok := at.trader.(*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)
}
@@ -448,7 +395,7 @@ func (at *AutoTrader) Run() error {
// Start Bitget order sync if using Bitget exchange
if at.exchange == "bitget" {
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
if bitgetTrader, ok := at.trader.(*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)
}
@@ -456,7 +403,7 @@ func (at *AutoTrader) Run() error {
// Start Aster order sync if using Aster exchange
if at.exchange == "aster" {
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
if asterTrader, ok := at.trader.(*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)
}
@@ -464,28 +411,12 @@ func (at *AutoTrader) Run() error {
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
if binanceTrader, ok := at.trader.(*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()
@@ -603,26 +534,15 @@ func (at *AutoTrader) runCycle() error {
return fmt.Errorf("failed to build trading context: %w", err)
}
// 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
}
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
at.saveEquitySnapshot(ctx)
logger.Info(strings.Repeat("=", 70))
for _, coin := range ctx.CandidateCoins {
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
@@ -901,19 +821,14 @@ 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 {
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))
}
return nil, fmt.Errorf("trader has no strategy engine configured")
}
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
@@ -1135,7 +1050,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
}
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
@@ -1252,7 +1167,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
}
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
@@ -1351,7 +1266,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
@@ -1415,7 +1330,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
// Get current price
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
@@ -2011,7 +1926,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", "kucoin", "gate":
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
return
}
@@ -2209,22 +2124,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
@@ -2352,3 +2267,4 @@ func getSideFromAction(action string) string {
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
return at.trader.GetOpenOrders(symbol)
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package binance
package trader
import (
"context"
@@ -7,7 +7,6 @@ import (
"fmt"
"nofx/hook"
"nofx/logger"
"nofx/trader/types"
"strconv"
"strings"
"sync"
@@ -719,7 +718,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
// PlaceLimitOrder places a limit order for grid trading
// This implements the GridTrader interface for FuturesTrader
func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity to correct precision
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
@@ -771,7 +770,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Li
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{
return &LimitOrderResult{
OrderID: fmt.Sprintf("%d", order.OrderID),
ClientID: order.ClientOrderID,
Symbol: order.Symbol,
@@ -897,8 +896,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
@@ -914,7 +913,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, types.OpenOrder{
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
@@ -937,7 +936,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error)
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, types.OpenOrder{
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
@@ -1248,14 +1247,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) ([]types.ClosedPnLRecord, error) {
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]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 []types.ClosedPnLRecord
var records []ClosedPnLRecord
for _, trade := range trades {
if trade.RealizedPnL == 0 {
continue // Skip opening trades
@@ -1284,7 +1283,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
}
}
records = append(records, types.ClosedPnLRecord{
records = append(records, ClosedPnLRecord{
Symbol: trade.Symbol,
Side: side,
EntryPrice: entryPrice,
@@ -1305,7 +1304,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.Cl
// 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) ([]types.TradeRecord, error) {
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1323,7 +1322,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
return nil, fmt.Errorf("failed to get income history: %w", err)
}
var trades []types.TradeRecord
var trades []TradeRecord
for _, income := range incomes {
pnl, _ := strconv.ParseFloat(income.Income, 64)
if pnl == 0 {
@@ -1332,7 +1331,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
// Income API doesn't provide full trade details, create a minimal record
// This is mainly used for detecting recent closures, not historical reconstruction
trade := types.TradeRecord{
trade := TradeRecord{
TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol,
RealizedPnL: pnl,
@@ -1348,7 +1347,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.Trade
// 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) ([]types.TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1365,14 +1364,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 []types.TradeRecord
var trades []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 := types.TradeRecord{
trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),
@@ -1391,7 +1390,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) ([]types.TradeRecord, error) {
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
if limit <= 0 {
limit = 100
}
@@ -1408,14 +1407,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 []types.TradeRecord
var trades []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 := types.TradeRecord{
trade := TradeRecord{
TradeID: strconv.FormatInt(at.ID, 10),
Symbol: at.Symbol,
Side: string(at.Side),

View File

@@ -1,4 +1,4 @@
package binance
package trader
import (
"encoding/json"
@@ -11,8 +11,6 @@ import (
"github.com/adshao/go-binance/v2/futures"
"github.com/stretchr/testify/assert"
"nofx/trader/testutil"
"nofx/trader/types"
)
// ============================================================
@@ -22,8 +20,8 @@ import (
// BinanceFuturesTestSuite Binance Futures trader test suite
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
type BinanceFuturesTestSuite struct {
*testutil.TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
*TraderTestSuite // Embeds base test suite
mockServer *httptest.Server
}
// NewBinanceFuturesTestSuite Creates Binance Futures test suite
@@ -272,13 +270,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
client.HTTPClient = mockServer.Client()
// Create FuturesTrader
traderInstance := &FuturesTrader{
trader := &FuturesTrader{
client: client,
cacheDuration: 0, // disable cache for testing
}
// Create base suite
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
baseSuite := NewTraderTestSuite(t, trader)
return &BinanceFuturesTestSuite{
TraderTestSuite: baseSuite,
@@ -300,7 +298,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
// TestFuturesTrader_InterfaceCompliance tests interface compliance
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
var _ types.Trader = (*FuturesTrader)(nil)
var _ Trader = (*FuturesTrader)(nil)
}
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
@@ -345,20 +343,20 @@ func TestNewFuturesTrader(t *testing.T) {
defer mockServer.Close()
// Test successful creation
t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
// Modify client to use mock server
t1.client.BaseURL = mockServer.URL
t1.client.HTTPClient = mockServer.Client()
trader.client.BaseURL = mockServer.URL
trader.client.HTTPClient = mockServer.Client()
assert.NotNil(t, t1)
assert.NotNil(t, t1.client)
assert.Equal(t, 15*time.Second, t1.cacheDuration)
assert.NotNil(t, trader)
assert.NotNil(t, trader.client)
assert.Equal(t, 15*time.Second, trader.cacheDuration)
}
// TestCalculatePositionSize tests position size calculation
func TestCalculatePositionSize(t *testing.T) {
ft := &FuturesTrader{}
trader := &FuturesTrader{}
tests := []struct {
name string
@@ -396,7 +394,7 @@ func TestCalculatePositionSize(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
quantity := trader.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,11 +1,10 @@
package binance
package trader
import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/store"
"nofx/trader/types"
"sort"
"strings"
"sync"
@@ -127,11 +126,11 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
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 []types.TradeRecord
var allTrades []TradeRecord
var failedSymbols []string
apiCalls := 0
for _, symbol := range changedSymbols {
var trades []types.TradeRecord
var trades []TradeRecord
var queryErr error
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package bitget
package trader
import (
"encoding/json"
@@ -48,82 +48,52 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
return nil, fmt.Errorf("failed to get fill history: %w", err)
}
// 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"`
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"`
}
// Try parsing as wrapped response first (fillList field)
var wrappedResp struct {
FillList []BitgetFill `json:"fillList"`
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("failed to parse fills: %w", err)
}
// Try direct array format (Bitget V2 API returns data as direct array)
var directFills []BitgetFill
trades := make([]BitgetTrade, 0, len(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 {
for _, fill := range resp.FillList {
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_single (open long), sell_single (close long)
// Bitget hedge mode: open + buy = open_long, close + sell = close_long
// Bitget one-way mode:
// - buy + open = open long
// - sell + open = open short
// - sell + close = close long
// - buy + close = close short
orderAction := "open_long"
side := strings.ToLower(fill.Side)
tradeSide := strings.ToLower(fill.TradeSide)
// 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 tradeSide == "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 {
@@ -138,8 +108,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
Side: fill.Side,
FillPrice: fillPrice,
FillQty: fillQty,
Fee: -fee, // Bitget returns negative fee, convert to positive
FeeAsset: feeAsset,
Fee: -fee, // Bitget returns negative fee
FeeAsset: fill.FeeCcy,
ExecTime: time.UnixMilli(cTime).UTC(),
ProfitLoss: profit,
OrderType: "MARKET",

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package bybit
package trader
import (
"context"
@@ -17,7 +17,6 @@ import (
"time"
bybit "github.com/bybit-exchange/bybit.go.api"
"nofx/trader/types"
)
// BybitTrader Bybit USDT Perpetual Futures Trader
@@ -901,13 +900,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) ([]types.ClosedPnLRecord, error) {
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]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) ([]types.ClosedPnLRecord, error) {
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]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
@@ -968,14 +967,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]typ
}
// parseClosedPnLResult parses the closed PnL result from Bybit API
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
data, ok := resultData.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid result format")
}
list, _ := data["list"].([]interface{})
var records []types.ClosedPnLRecord
var records []ClosedPnLRecord
for _, item := range list {
pnl, ok := item.(map[string]interface{})
@@ -1024,7 +1023,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
normalizedSide = "short"
}
record := types.ClosedPnLRecord{
record := ClosedPnLRecord{
Symbol: symbol,
Side: normalizedSide,
EntryPrice: avgEntryPrice,
@@ -1047,8 +1046,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.Clos
}
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
var result []types.OpenOrder
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
@@ -1089,7 +1088,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
displayType = stopOrderType
}
result = append(result, types.OpenOrder{
result = append(result, OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
@@ -1109,7 +1108,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
// PlaceLimitOrder places a limit order for grid trading
// Implements GridTrader interface
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
// Format quantity
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
if err != nil {
@@ -1170,7 +1169,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.Limi
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
req.Symbol, side, priceStr, qtyStr, orderID)
return &types.LimitOrderResult{
return &LimitOrderResult{
OrderID: orderID,
ClientID: req.ClientID,
Symbol: req.Symbol,

View File

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

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