mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-21 03:07:35 +08:00
Compare commits
68 Commits
5ea9a3990e
...
openclaw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a81993bb | ||
|
|
b73617fed3 | ||
|
|
4774348ed6 | ||
|
|
e638ba8d8f | ||
|
|
156bf04bcc | ||
|
|
af250825e7 | ||
|
|
c5c5ed2a4d | ||
|
|
6f77ed2fcb | ||
|
|
034c206874 | ||
|
|
fcb90b77ae | ||
|
|
7b9a0740c1 | ||
|
|
3ed0aec0ff | ||
|
|
8406f2f998 | ||
|
|
9a3017af6d | ||
|
|
aebca4b16c | ||
|
|
767d8629a3 | ||
|
|
ff1ca4460d | ||
|
|
d160301359 | ||
|
|
1bbd4b44ac | ||
|
|
b2ce123df1 | ||
|
|
97f309c9b5 | ||
|
|
13d70d2598 | ||
|
|
138bbb1242 | ||
|
|
ca87dbe3bb | ||
|
|
ea7b450a7e | ||
|
|
9fcf44af65 | ||
|
|
5f47dd13db | ||
|
|
b354eb8bf2 | ||
|
|
79a21890d8 | ||
|
|
bbd72c778c | ||
|
|
3168a18c0d | ||
|
|
73f1fe105d | ||
|
|
fa664ccae3 | ||
|
|
0210d0e4b5 | ||
|
|
27a7491cd1 | ||
|
|
3358c5a53e | ||
|
|
285053b7a4 | ||
|
|
c7039e6b4a | ||
|
|
06d6080751 | ||
|
|
64935b9d47 | ||
|
|
0000bc7f32 | ||
|
|
bdb2744845 | ||
|
|
4c525c19c6 | ||
|
|
95daa39f0b | ||
|
|
24700d3a73 | ||
|
|
ec582a6ec4 | ||
|
|
9bfa56e226 | ||
|
|
9ef67bdcd8 | ||
|
|
77d45690a6 | ||
|
|
b70b047f75 | ||
|
|
8896de2642 | ||
|
|
eb89a49b58 | ||
|
|
0b4f43d72b | ||
|
|
22f6ddc045 | ||
|
|
773857351f | ||
|
|
382e756328 | ||
|
|
87ef618b04 | ||
|
|
ca92b849cd | ||
|
|
23dbbf6bdd | ||
|
|
b32a3566e6 | ||
|
|
a5c4d35074 | ||
|
|
7b908a3e39 | ||
|
|
093d2a329d | ||
|
|
40474d258c | ||
|
|
e19e289c58 | ||
|
|
cca24e05c1 | ||
|
|
581ff57323 | ||
|
|
b137122b18 |
@@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false
|
||||
# Optional: External Services
|
||||
# ===========================================
|
||||
|
||||
# Telegram notifications (optional)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=10.
|
||||
DB_PORT=5432
|
||||
|
||||
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
120
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,100 +1,50 @@
|
||||
# Pull Request
|
||||
## Summary
|
||||
|
||||
> **📋 Choose Specialized Template**
|
||||
>
|
||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||
>
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||
>
|
||||
> **How to use?**
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
- Problem:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
---
|
||||
## Change Type
|
||||
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Docs
|
||||
- [ ] Security fix
|
||||
- [ ] Chore / infra
|
||||
|
||||
---
|
||||
## Scope
|
||||
|
||||
## 📝 Description
|
||||
- [ ] Trading engine / strategies
|
||||
- [ ] MCP / AI clients
|
||||
- [ ] API / server
|
||||
- [ ] Telegram bot / agent
|
||||
- [ ] Web UI / frontend
|
||||
- [ ] Config / deployment
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues
|
||||
## Linked Issues
|
||||
|
||||
- Closes #
|
||||
- Related to #
|
||||
- Related #
|
||||
|
||||
---
|
||||
## Testing
|
||||
|
||||
## 📋 Changes Made
|
||||
What you verified and how:
|
||||
|
||||
<!-- List the specific changes made -->
|
||||
-
|
||||
-
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Manual testing done (describe below)
|
||||
|
||||
---
|
||||
## Security Impact
|
||||
|
||||
## 🧪 Testing
|
||||
- Secrets/keys handling changed? (`Yes/No`)
|
||||
- New/changed API endpoints? (`Yes/No`)
|
||||
- User input validation affected? (`Yes/No`)
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
## Compatibility
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes
|
||||
|
||||
<!-- Any additional information or context -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution!**
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, upgrade steps:
|
||||
|
||||
202
.github/workflows/pr-template-suggester.yml
vendored
202
.github/workflows/pr-template-suggester.yml
vendored
@@ -1,22 +1,18 @@
|
||||
name: PR Template Suggester
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest-template:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Analyze PR files and auto-apply template
|
||||
- name: Analyze PR and apply labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -25,166 +21,72 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
let additions = 0, deletions = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.filename.toLowerCase();
|
||||
if (filename.endsWith('.go')) goFiles++;
|
||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||
else if (filename.endsWith('.md')) mdFiles++;
|
||||
const name = file.filename.toLowerCase();
|
||||
additions += file.additions || 0;
|
||||
deletions += file.deletions || 0;
|
||||
if (name.endsWith('.go')) goFiles++;
|
||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
||||
else if (name.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
if (totalFiles === 0) return;
|
||||
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
// --- Scope label ---
|
||||
const labels = [];
|
||||
if (goFiles / totalFiles > 0.5) labels.push('backend');
|
||||
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
|
||||
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
|
||||
else labels.push('fullstack');
|
||||
|
||||
if (goFiles / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||
} else if (mdFiles / totalFiles > 0.7) {
|
||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||
// --- Size label (like OpenClaw) ---
|
||||
const totalChanged = additions + deletions;
|
||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||
let sizeLabel = 'size: XL';
|
||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||
else if (totalChanged < 500) sizeLabel = 'size: M';
|
||||
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
||||
labels.push(sizeLabel);
|
||||
|
||||
// Ensure size labels exist
|
||||
for (const sl of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
// 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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Apply labels
|
||||
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
|
||||
labels: labels,
|
||||
});
|
||||
|
||||
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
body: templateContent
|
||||
});
|
||||
|
||||
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
||||
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
||||
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
||||
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
||||
'✨ You can now fill in the relevant information in the PR description!';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: notifyComment
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch or apply template: ' + error.message);
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
||||
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
||||
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fallbackComment
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('PR body has content, sending suggestion only');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
||||
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
||||
'**如何使用 | How to use**\n' +
|
||||
'1. 编辑PR描述 | Edit PR description\n' +
|
||||
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
||||
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
||||
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
||||
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
||||
'_This is an automated suggestion. You may continue using the current template._';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
||||
console.log('PR already uses a specific template');
|
||||
} else {
|
||||
console.log('No specific template suggestion needed - mixed changes');
|
||||
}
|
||||
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
|
||||
37
README.ja.md
37
README.ja.md
@@ -103,6 +103,43 @@ Binance互換の分散型無期限先物取引所!
|
||||
|
||||
---
|
||||
|
||||
## 対応取引所
|
||||
|
||||
### CEX(中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX(分散型無期限取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録(手数料割引) |
|
||||
|:-------|:----------:|:-------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 対応AIモデル
|
||||
|
||||
| AIモデル | ステータス | APIキー取得 |
|
||||
|:---------|:----------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [APIキー取得](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [APIキー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [APIキー取得](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [APIキー取得](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [APIキー取得](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [APIキー取得](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [APIキー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 📸 スクリーンショット
|
||||
|
||||
### 🏆 競争モード - リアルタイムAIバトル
|
||||
|
||||
38
README.md
38
README.md
@@ -38,7 +38,7 @@
|
||||
### Core Features
|
||||
|
||||
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
||||
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
||||
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
||||
@@ -78,33 +78,35 @@ To use NOFX, you'll need:
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -832,6 +832,8 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
|
||||
provider = "google"
|
||||
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||
provider = "deepseek"
|
||||
} else if strings.Contains(modelNameLower, "minimax") {
|
||||
provider = "minimax"
|
||||
} else if model.CustomAPIURL != "" {
|
||||
provider = "custom"
|
||||
} else {
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// MockUser Mock user structure
|
||||
type MockUser struct {
|
||||
ID int
|
||||
Email string
|
||||
OTPSecret string
|
||||
OTPVerified bool
|
||||
}
|
||||
|
||||
// TestOTPRefetchLogic Test OTP refetch logic
|
||||
func TestOTPRefetchLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
existingUser *MockUser
|
||||
userExists bool
|
||||
expectedAction string // "allow_refetch", "reject_duplicate", "create_new"
|
||||
expectedMessage string
|
||||
}{
|
||||
{
|
||||
name: "New user registration - email does not exist",
|
||||
existingUser: nil,
|
||||
userExists: false,
|
||||
expectedAction: "create_new",
|
||||
expectedMessage: "Create new user",
|
||||
},
|
||||
{
|
||||
name: "Incomplete OTP verification - allow refetch",
|
||||
existingUser: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "SECRET123",
|
||||
OTPVerified: false,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "allow_refetch",
|
||||
expectedMessage: "Incomplete registration detected, please continue OTP setup",
|
||||
},
|
||||
{
|
||||
name: "Completed OTP verification - reject duplicate registration",
|
||||
existingUser: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET456",
|
||||
OTPVerified: true,
|
||||
},
|
||||
userExists: true,
|
||||
expectedAction: "reject_duplicate",
|
||||
expectedMessage: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate logic processing flow
|
||||
var actualAction string
|
||||
var actualMessage string
|
||||
|
||||
if !tt.userExists {
|
||||
// User does not exist, create new user
|
||||
actualAction = "create_new"
|
||||
actualMessage = "Create new user"
|
||||
} else {
|
||||
// User exists, check OTP verification status
|
||||
if !tt.existingUser.OTPVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualAction = "allow_refetch"
|
||||
actualMessage = "Incomplete registration detected, please continue OTP setup"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualAction = "reject_duplicate"
|
||||
actualMessage = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify results
|
||||
if actualAction != tt.expectedAction {
|
||||
t.Errorf("Action mismatch: got %s, want %s", actualAction, tt.expectedAction)
|
||||
}
|
||||
if actualMessage != tt.expectedMessage {
|
||||
t.Errorf("Message mismatch: got %s, want %s", actualMessage, tt.expectedMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOTPVerificationStates Test OTP verification state determination
|
||||
func TestOTPVerificationStates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
otpVerified bool
|
||||
shouldAllowRefetch bool
|
||||
}{
|
||||
{
|
||||
name: "OTP verified - disallow refetch",
|
||||
otpVerified: true,
|
||||
shouldAllowRefetch: false,
|
||||
},
|
||||
{
|
||||
name: "OTP not verified - allow refetch",
|
||||
otpVerified: false,
|
||||
shouldAllowRefetch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate verification logic
|
||||
allowRefetch := !tt.otpVerified
|
||||
|
||||
if allowRefetch != tt.shouldAllowRefetch {
|
||||
t.Errorf("Refetch logic error: OTPVerified=%v, allowRefetch=%v, expected=%v",
|
||||
tt.otpVerified, allowRefetch, tt.shouldAllowRefetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistrationFlow Test complete registration flow logic branches
|
||||
func TestRegistrationFlow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
scenario string
|
||||
userExists bool
|
||||
otpVerified bool
|
||||
expectHTTPCode int // Simulated HTTP status code
|
||||
expectResponse string
|
||||
}{
|
||||
{
|
||||
name: "Scenario 1: New user first registration",
|
||||
scenario: "New user first accesses registration endpoint",
|
||||
userExists: false,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Create user and return OTP setup information",
|
||||
},
|
||||
{
|
||||
name: "Scenario 2: User re-accesses after interrupting registration",
|
||||
scenario: "User registered previously but did not complete OTP setup, now re-accessing",
|
||||
userExists: true,
|
||||
otpVerified: false,
|
||||
expectHTTPCode: 200,
|
||||
expectResponse: "Return existing user's OTP information, allow continuation",
|
||||
},
|
||||
{
|
||||
name: "Scenario 3: Registered user attempts duplicate registration",
|
||||
scenario: "User already completed registration, attempts to register again with same email",
|
||||
userExists: true,
|
||||
otpVerified: true,
|
||||
expectHTTPCode: 409, // Conflict
|
||||
expectResponse: "Email already registered",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate registration flow logic
|
||||
var actualHTTPCode int
|
||||
var actualResponse string
|
||||
|
||||
if !tt.userExists {
|
||||
// New user, create and return OTP information
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Create user and return OTP setup information"
|
||||
} else {
|
||||
// User exists
|
||||
if !tt.otpVerified {
|
||||
// OTP verification incomplete, allow refetch
|
||||
actualHTTPCode = 200
|
||||
actualResponse = "Return existing user's OTP information, allow continuation"
|
||||
} else {
|
||||
// Verification completed, reject duplicate registration
|
||||
actualHTTPCode = 409
|
||||
actualResponse = "Email already registered"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
if actualHTTPCode != tt.expectHTTPCode {
|
||||
t.Errorf("HTTP code mismatch: got %d, want %d (scenario: %s)",
|
||||
actualHTTPCode, tt.expectHTTPCode, tt.scenario)
|
||||
}
|
||||
if actualResponse != tt.expectResponse {
|
||||
t.Errorf("Response mismatch: got %s, want %s (scenario: %s)",
|
||||
actualResponse, tt.expectResponse, tt.scenario)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s: HTTP %d, %s", tt.scenario, actualHTTPCode, actualResponse)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCases Test edge cases
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user *MockUser
|
||||
expectAllow bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User ID is 0 - treated as new user",
|
||||
user: &MockUser{
|
||||
ID: 0,
|
||||
Email: "new@example.com",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "ID of 0 usually indicates user has not been created yet",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret is empty - still can refetch",
|
||||
user: &MockUser{
|
||||
ID: 1,
|
||||
Email: "test@example.com",
|
||||
OTPSecret: "",
|
||||
OTPVerified: false,
|
||||
},
|
||||
expectAllow: true,
|
||||
description: "Even if OTPSecret is empty, as long as not verified, refetch is allowed",
|
||||
},
|
||||
{
|
||||
name: "OTPSecret exists but already verified - not allowed",
|
||||
user: &MockUser{
|
||||
ID: 2,
|
||||
Email: "verified@example.com",
|
||||
OTPSecret: "SECRET789",
|
||||
OTPVerified: true,
|
||||
},
|
||||
expectAllow: false,
|
||||
description: "Users with verified OTP cannot refetch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Core logic: as long as OTPVerified is false, refetch is allowed
|
||||
allowRefetch := !tt.user.OTPVerified
|
||||
|
||||
if allowRefetch != tt.expectAllow {
|
||||
t.Errorf("Edge case failed: %s\nUser: ID=%d, OTPVerified=%v\nExpected allow=%v, got=%v",
|
||||
tt.description, tt.user.ID, tt.user.OTPVerified, tt.expectAllow, allowRefetch)
|
||||
}
|
||||
|
||||
t.Logf("✓ %s", tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
66
api/route_registry.go
Normal file
66
api/route_registry.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RouteDoc holds documentation for a single API route.
|
||||
type RouteDoc struct {
|
||||
Method string
|
||||
Path string
|
||||
Description string
|
||||
Schema string // optional: full parameter/body schema documentation
|
||||
}
|
||||
|
||||
// routeRegistry stores all documented routes. Populated via s.route() calls in setupRoutes.
|
||||
var routeRegistry []RouteDoc
|
||||
|
||||
// route registers an HTTP route with a one-line description.
|
||||
func (s *Server) route(g *gin.RouterGroup, method, path, description string, h gin.HandlerFunc) {
|
||||
s.routeWithSchema(g, method, path, description, "", h)
|
||||
}
|
||||
|
||||
// routeWithSchema registers an HTTP route with full parameter schema documentation.
|
||||
// schema is injected verbatim into the API docs seen by the LLM.
|
||||
func (s *Server) routeWithSchema(g *gin.RouterGroup, method, path, description, schema string, h gin.HandlerFunc) {
|
||||
fullPath := strings.TrimSuffix(g.BasePath(), "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
routeRegistry = append(routeRegistry, RouteDoc{
|
||||
Method: method,
|
||||
Path: fullPath,
|
||||
Description: description,
|
||||
Schema: schema,
|
||||
})
|
||||
switch method {
|
||||
case "GET":
|
||||
g.GET(path, h)
|
||||
case "POST":
|
||||
g.POST(path, h)
|
||||
case "PUT":
|
||||
g.PUT(path, h)
|
||||
case "DELETE":
|
||||
g.DELETE(path, h)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAPIDocs returns formatted API documentation for injection into the LLM system prompt.
|
||||
// Routes with schema documentation include full parameter details.
|
||||
func GetAPIDocs() string {
|
||||
var sb strings.Builder
|
||||
for _, r := range routeRegistry {
|
||||
sb.WriteString(fmt.Sprintf("%-8s %s\n", r.Method, r.Path))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", r.Description))
|
||||
if r.Schema != "" {
|
||||
// Indent each schema line for readability
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Schema), "\n") {
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(line)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
749
api/server.go
749
api/server.go
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy Create strategy
|
||||
// handleCreateStrategy Create strategy.
|
||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -147,7 +148,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use default config when none provided
|
||||
if req.Config == nil {
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
@@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleUpdateStrategy Update strategy
|
||||
// handleUpdateStrategy Update strategy.
|
||||
// The incoming config is merged with the existing one: top-level sections present in the
|
||||
// request overwrite the corresponding existing sections; absent sections are preserved.
|
||||
// This prevents partial updates from zeroing out unmentioned fields.
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
@@ -215,7 +230,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
@@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
// Start with the existing config as base — preserves all unmentioned fields.
|
||||
var mergedConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
|
||||
// If existing config is corrupt, start from zero
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = existing.Name
|
||||
}
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
description = existing.Description
|
||||
}
|
||||
|
||||
configJSON, err := json.Marshal(mergedConfig)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
@@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Config: string(configJSON),
|
||||
IsPublic: req.IsPublic,
|
||||
ConfigVisible: req.ConfigVisible,
|
||||
@@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -625,6 +665,18 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
case "openai":
|
||||
aiClient = mcp.NewOpenAIClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "minimax":
|
||||
aiClient = mcp.NewMiniMaxClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "blockrun-base":
|
||||
aiClient = mcp.NewBlockRunBaseClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "blockrun-sol":
|
||||
aiClient = mcp.NewBlockRunSolClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "claw402":
|
||||
aiClient = mcp.NewClaw402Client()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
|
||||
35
auth/auth.go
35
auth/auth.go
@@ -1,15 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
|
||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||
const maxBlacklistEntries = 100_000
|
||||
|
||||
// OTPIssuer is the OTP issuer name
|
||||
const OTPIssuer = "nofxAI"
|
||||
|
||||
// SetJWTSecret sets the JWT secret key
|
||||
func SetJWTSecret(secret string) {
|
||||
JWTSecret = []byte(secret)
|
||||
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: OTPIssuer,
|
||||
AccountName: uuid.New().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return key.Secret(), nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies OTP code
|
||||
func VerifyOTP(secret, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// GenerateJWT generates JWT token
|
||||
func GenerateJWT(userID, email string) (string, error) {
|
||||
claims := Claims{
|
||||
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GetOTPQRCodeURL gets OTP QR code URL
|
||||
func GetOTPQRCodeURL(secret, email string) string {
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,34 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
||||
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return oaiC, nil
|
||||
case "minimax":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("minimax provider requires api key")
|
||||
}
|
||||
mmC := mcp.NewMiniMaxClientWithOptions()
|
||||
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return mmC, nil
|
||||
case "blockrun-base":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-base provider requires wallet private key")
|
||||
}
|
||||
brBase := mcp.NewBlockRunBaseClient()
|
||||
brBase.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brBase, nil
|
||||
case "blockrun-sol":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-sol provider requires wallet keypair")
|
||||
}
|
||||
brSol := mcp.NewBlockRunSolClient()
|
||||
brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brSol, nil
|
||||
case "claw402":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("claw402 provider requires wallet private key")
|
||||
}
|
||||
claw := mcp.NewClaw402Client()
|
||||
claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return claw, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
@@ -125,6 +153,11 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.MiniMaxClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
// Fall back to a new default client
|
||||
return mcp.NewClient().(*mcp.Client)
|
||||
|
||||
@@ -17,8 +17,6 @@ type Config struct {
|
||||
// Service configuration
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||
|
||||
// Database configuration
|
||||
DBType string // sqlite or postgres
|
||||
@@ -44,14 +42,13 @@ type Config struct {
|
||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||
AlpacaSecretKey string // Alpaca secret key
|
||||
TwelveDataKey string // TwelveData API key for forex & metals
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
func Init() {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
RegistrationEnabled: true,
|
||||
MaxUsers: 10, // Default: 10 users allowed
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
// Database defaults
|
||||
DBType: "sqlite",
|
||||
@@ -71,16 +68,6 @@ func Init() {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
}
|
||||
|
||||
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||
}
|
||||
|
||||
if v := os.Getenv("MAX_USERS"); v != "" {
|
||||
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
|
||||
cfg.MaxUsers = maxUsers
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
||||
cfg.APIServerPort = port
|
||||
|
||||
@@ -97,6 +97,14 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
||||
client = mcp.NewGrokClient()
|
||||
case "kimi":
|
||||
client = mcp.NewKimiClient()
|
||||
case "minimax":
|
||||
client = mcp.NewMiniMaxClient()
|
||||
case "blockrun-base":
|
||||
client = mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
client = mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
client = mcp.NewClaw402Client()
|
||||
default:
|
||||
client = mcp.New()
|
||||
}
|
||||
|
||||
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ⚠️ Official Accounts & Anti-Impersonation Notice
|
||||
|
||||
## Legal Entity
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| Company Name | **Cryonic Holdings Limited** |
|
||||
| Company No. | 2193977 |
|
||||
| Jurisdiction | British Virgin Islands |
|
||||
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
|
||||
| Contact Email | 0xccfelix@gmail.com |
|
||||
|
||||
## Official Social Media & Channels
|
||||
|
||||
| Platform | Official Account | Link | Status |
|
||||
|----------|-----------------|------|--------|
|
||||
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
|
||||
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
|
||||
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
|
||||
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
|
||||
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
|
||||
|
||||
## ⛔ Known Impersonation Accounts
|
||||
|
||||
The following accounts are **NOT affiliated** with the NoFx project:
|
||||
|
||||
| Platform | Account | Status |
|
||||
|----------|---------|--------|
|
||||
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
|
||||
|
||||
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
|
||||
|
||||
## How to Verify Authenticity
|
||||
|
||||
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
|
||||
2. Our GitHub repository sidebar links directly to our official Twitter
|
||||
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
|
||||
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
|
||||
5. Official contact email: 0xccfelix@gmail.com
|
||||
|
||||
## Report Impersonation
|
||||
|
||||
If you encounter accounts impersonating NoFx, please:
|
||||
1. Report them on the respective platform
|
||||
2. Open an issue in this repository to notify our team
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-01*
|
||||
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*
|
||||
@@ -241,6 +241,7 @@ NOFX offers bounties for valuable contributions:
|
||||
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,19 @@ Use custom AI models or third-party OpenAI-compatible APIs:
|
||||
|
||||
---
|
||||
|
||||
### 💳 BlockRun Wallet (Pay-per-Request, No API Key)
|
||||
|
||||
Access all top AI models by paying with USDC — no API key signup required.
|
||||
|
||||
| Provider | Guide | Payment Network |
|
||||
|----------|-------|-----------------|
|
||||
| BlockRun (Base Wallet) | [blockrun-base-wallet.md](blockrun-base-wallet.md) | Base (EVM) · USDC |
|
||||
| BlockRun (Solana Wallet) | [blockrun-sol-wallet.md](blockrun-sol-wallet.md) | Solana · USDC |
|
||||
|
||||
**How it works:** Each AI request automatically pays a micro-USDC fee via the [x402 payment protocol](https://blockrun.ai). Your private key signs the payment authorization — no funds leave your wallet until the AI response is delivered.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Prerequisites
|
||||
|
||||
Before starting, ensure you have:
|
||||
|
||||
126
docs/getting-started/blockrun-base-wallet.md
Normal file
126
docs/getting-started/blockrun-base-wallet.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# BlockRun Base (EVM) Wallet Setup Guide
|
||||
|
||||
This guide explains how to use a Base network EVM wallet to pay for AI usage through BlockRun — no API key required.
|
||||
|
||||
**Language:** [English](blockrun-base-wallet.md) | [中文](blockrun-base-wallet.zh-CN.md)
|
||||
|
||||
## What is BlockRun?
|
||||
|
||||
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
|
||||
|
||||
NOFX integrates BlockRun via the **x402 micropayment protocol**: each AI inference request automatically pays a small USDC fee directly from your wallet. You only pay for what you use.
|
||||
|
||||
## Why Use BlockRun?
|
||||
|
||||
| Feature | Traditional API Key | BlockRun Wallet |
|
||||
|---------|-------------------|-----------------|
|
||||
| Setup | Register + billing | Just a wallet address |
|
||||
| Cost model | Monthly subscription | Pay-per-request |
|
||||
| Models | One provider | All top models |
|
||||
| Privacy | Account required | Pseudonymous |
|
||||
| Control | Rate limits apply | Your wallet, your budget |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An EVM wallet with USDC on **Base network** (chain ID 8453)
|
||||
- The wallet private key (hex format: `0x...`)
|
||||
|
||||
### Getting USDC on Base
|
||||
|
||||
1. Buy USDC on Coinbase and withdraw to Base, **or**
|
||||
2. Bridge USDC from Ethereum using [bridge.base.org](https://bridge.base.org), **or**
|
||||
3. Swap on [Aerodrome](https://aerodrome.finance) or [Uniswap](https://app.uniswap.org) on Base
|
||||
|
||||
> **Tip:** A few dollars of USDC is enough to start — each AI call costs fractions of a cent.
|
||||
|
||||
## Step 1: Get Your Wallet Private Key
|
||||
|
||||
> ⚠️ **Security Warning:** Never share your private key with anyone. Use a dedicated trading wallet, not your main holdings wallet.
|
||||
|
||||
**Option A — Create a new wallet (recommended):**
|
||||
1. Open MetaMask → Create New Account
|
||||
2. Go to Account Details → Export Private Key
|
||||
3. Copy the hex key (starts with `0x`)
|
||||
|
||||
**Option B — Use an existing wallet:**
|
||||
1. MetaMask → Account Details → Export Private Key
|
||||
2. Enter your MetaMask password to reveal the key
|
||||
|
||||
**Option C — Generate via CLI:**
|
||||
```bash
|
||||
# Using cast (foundry)
|
||||
cast wallet new
|
||||
# Output: Address: 0x... | Private key: 0x...
|
||||
```
|
||||
|
||||
## Step 2: Fund the Wallet with USDC on Base
|
||||
|
||||
Send USDC to your wallet address on Base network:
|
||||
- **USDC contract:** `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`
|
||||
- **Network:** Base (chain ID 8453)
|
||||
- **Recommended starting amount:** $5–$20 USDC
|
||||
|
||||
Check your balance at [basescan.org](https://basescan.org).
|
||||
|
||||
## Step 3: Configure in NOFX
|
||||
|
||||
1. Open NOFX at `http://localhost:3000`
|
||||
2. Log in and go to **Config** tab
|
||||
3. Click **+ Add AI Model**
|
||||
4. In Step 0, scroll to **Via BlockRun Wallet** section
|
||||
5. Select **BlockRun · Base Wallet**
|
||||
6. In Step 1, configure:
|
||||
- **Wallet Private Key:** Your hex private key (`0x...`)
|
||||
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
|
||||
7. Click **Save**
|
||||
|
||||
## How Payment Works
|
||||
|
||||
When NOFX sends an AI request:
|
||||
|
||||
1. Request goes to `https://blockrun.ai/api/v1/chat/completions`
|
||||
2. Server responds with HTTP `402 Payment Required` + payment details
|
||||
3. NOFX signs a **ERC-3009 TransferWithAuthorization** (EIP-712) with your private key
|
||||
4. Payment signature is attached and request is retried
|
||||
5. BlockRun verifies the signature, routes the request to the AI model, and charges USDC
|
||||
|
||||
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
|
||||
|
||||
## Available Models via BlockRun
|
||||
|
||||
| Model ID | Provider | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `gpt-5.4` | OpenAI | Flagship (default) |
|
||||
| `claude-opus-4.6` | Anthropic | Flagship |
|
||||
| `gemini-3.1-pro` | Google | Flagship |
|
||||
| `grok-3` | xAI | Flagship |
|
||||
| `deepseek-chat` | DeepSeek | Flagship |
|
||||
| `minimax-m2.5` | MiniMax | Flagship |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- ✅ Use a **dedicated wallet** with only trading budget, not your main wallet
|
||||
- ✅ Keep only a small USDC balance (top up as needed)
|
||||
- ✅ Your private key is encrypted at rest in NOFX's database
|
||||
- ✅ Signatures are spend-limited — each signature authorizes only the exact amount for one request
|
||||
- ❌ Never export or share your private key outside of NOFX
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `no private key set` | Check your key was saved correctly; re-enter in Config |
|
||||
| `payment retry failed` | Ensure you have USDC on **Base** (not Ethereum mainnet) |
|
||||
| `invalid private key` | Key must be hex format with `0x` prefix, 66 chars total |
|
||||
| Payment deducted but no response | Check BlockRun status at [blockrun.ai](https://blockrun.ai) |
|
||||
| Slow responses | Try selecting a specific model instead of "Auto" |
|
||||
|
||||
## Monitoring Spend
|
||||
|
||||
Check your USDC balance and transaction history at:
|
||||
- [Basescan](https://basescan.org) — search your wallet address
|
||||
- [BlockRun dashboard](https://blockrun.ai) — usage history
|
||||
|
||||
---
|
||||
|
||||
[← Back to Getting Started](README.md)
|
||||
120
docs/getting-started/blockrun-sol-wallet.md
Normal file
120
docs/getting-started/blockrun-sol-wallet.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# BlockRun Solana Wallet Setup Guide
|
||||
|
||||
This guide explains how to use a Solana wallet to pay for AI usage through BlockRun — no API key required.
|
||||
|
||||
**Language:** [English](blockrun-sol-wallet.md) | [中文](blockrun-sol-wallet.zh-CN.md)
|
||||
|
||||
## What is BlockRun?
|
||||
|
||||
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
|
||||
|
||||
NOFX integrates BlockRun via the **x402 micropayment protocol** on Solana: each AI inference request automatically pays a small USDC fee directly from your wallet.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Solana wallet with USDC on **Solana mainnet**
|
||||
- The wallet private key (base58-encoded, 64 bytes — standard Solana keypair format)
|
||||
|
||||
### Getting USDC on Solana
|
||||
|
||||
1. Buy SOL on any exchange and withdraw to your Solana wallet, then swap to USDC on [Jupiter](https://jup.ag), **or**
|
||||
2. Buy USDC directly on an exchange and withdraw to Solana, **or**
|
||||
3. Bridge from other chains using [Wormhole](https://wormhole.com)
|
||||
|
||||
> **Tip:** A few dollars of USDC is plenty to start.
|
||||
|
||||
## Step 1: Export Your Solana Private Key
|
||||
|
||||
> ⚠️ **Security Warning:** Use a dedicated wallet for NOFX — not your main holdings wallet.
|
||||
|
||||
**From Phantom Wallet:**
|
||||
1. Open Phantom → Settings (gear icon)
|
||||
2. Security & Privacy → Export Private Key
|
||||
3. Enter your password
|
||||
4. Copy the base58 key (looks like: `5J...` — a long string of ~88 characters)
|
||||
|
||||
**From Solflare:**
|
||||
1. Settings → Export Private Key
|
||||
2. The key is displayed in base58 format
|
||||
|
||||
**From CLI (solana-keygen):**
|
||||
```bash
|
||||
# View existing keypair
|
||||
cat ~/.config/solana/id.json
|
||||
# This is a JSON array — convert to base58 using:
|
||||
solana-keygen pubkey ~/.config/solana/id.json
|
||||
```
|
||||
|
||||
> **Note:** NOFX accepts the **base58-encoded 64-byte keypair** (as exported by Phantom/Solflare). This is the standard format for Solana private keys.
|
||||
|
||||
## Step 2: Fund the Wallet with USDC on Solana
|
||||
|
||||
Send USDC to your Solana wallet:
|
||||
- **USDC SPL token mint:** `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
|
||||
- **Network:** Solana Mainnet
|
||||
- **Recommended starting amount:** $5–$20 USDC
|
||||
|
||||
Check your balance at [solscan.io](https://solscan.io) or in your wallet app.
|
||||
|
||||
## Step 3: Configure in NOFX
|
||||
|
||||
1. Open NOFX at `http://localhost:3000`
|
||||
2. Log in and go to **Config** tab
|
||||
3. Click **+ Add AI Model**
|
||||
4. In Step 0, scroll to **Via BlockRun Wallet** section
|
||||
5. Select **BlockRun · Solana Wallet**
|
||||
6. In Step 1, configure:
|
||||
- **Wallet Private Key:** Your base58-encoded Solana private key
|
||||
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
|
||||
7. Click **Save**
|
||||
|
||||
## How Payment Works
|
||||
|
||||
When NOFX sends an AI request:
|
||||
|
||||
1. Request goes to `https://sol.blockrun.ai/api/v1/chat/completions`
|
||||
2. Server responds with HTTP `402 Payment Required` + payment details (nonce, recipient, amount)
|
||||
3. NOFX signs the payment message `blockrun-payment:{nonce}:{recipient}:{amount}` with your **Ed25519** private key
|
||||
4. Payment signature is attached and request is retried
|
||||
5. BlockRun verifies the Ed25519 signature on-chain and routes to the AI model
|
||||
|
||||
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
|
||||
|
||||
## Available Models via BlockRun
|
||||
|
||||
| Model ID | Provider | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `gpt-5.4` | OpenAI | Flagship (default) |
|
||||
| `claude-opus-4.6` | Anthropic | Flagship |
|
||||
| `gemini-3.1-pro` | Google | Flagship |
|
||||
| `grok-3` | xAI | Flagship |
|
||||
| `deepseek-chat` | DeepSeek | Flagship |
|
||||
| `minimax-m2.5` | MiniMax | Flagship |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- ✅ Use a **dedicated trading wallet** with only your AI budget
|
||||
- ✅ Keep only a small USDC balance (top up as needed)
|
||||
- ✅ Your private key is AES-256 encrypted at rest in NOFX's database
|
||||
- ✅ Ed25519 signatures are one-time — each authorizes only one specific payment
|
||||
- ❌ Never use your main SOL holdings wallet as the NOFX trading wallet
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `unexpected key length` | Ensure you exported the full 64-byte keypair (not just the 32-byte seed) |
|
||||
| `failed to decode base58` | Key must be base58 encoded (standard Phantom/Solflare export format) |
|
||||
| `payment retry failed` | Ensure you have USDC on **Solana mainnet** (not devnet) |
|
||||
| No response from server | Check `sol.blockrun.ai` is reachable from your server |
|
||||
| Slow responses | Try selecting a specific model instead of "Auto" |
|
||||
|
||||
## Monitoring Spend
|
||||
|
||||
Check your USDC balance and transaction history at:
|
||||
- [Solscan](https://solscan.io) — search your wallet address, filter by USDC token
|
||||
- [BlockRun dashboard](https://blockrun.ai) — usage history
|
||||
|
||||
---
|
||||
|
||||
[← Back to Getting Started](README.md)
|
||||
@@ -24,7 +24,7 @@
|
||||
### コア機能
|
||||
|
||||
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
@@ -63,6 +63,8 @@ NOFXを使用するには以下が必要です:
|
||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (分散型永久先物取引所)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
### 핵심 기능
|
||||
|
||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
@@ -63,6 +63,8 @@ NOFX를 사용하려면 다음이 필요합니다:
|
||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ 지원 | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ 지원 | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
### Основные функции
|
||||
|
||||
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
||||
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||
@@ -63,6 +63,8 @@
|
||||
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Поддерживается | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Поддерживается | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Децентрализованные биржи)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
### Основні функції
|
||||
|
||||
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
||||
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||
@@ -63,6 +63,8 @@
|
||||
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Підтримується | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Підтримується | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Децентралізовані біржі)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
### Tính Năng Chính
|
||||
|
||||
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
|
||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
|
||||
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
|
||||
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
||||
@@ -63,6 +63,8 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
|
||||
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Hỗ trợ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Hỗ trợ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Sàn Phi Tập Trung)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
### 核心功能
|
||||
|
||||
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
||||
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
||||
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
||||
@@ -75,6 +75,8 @@
|
||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ 已支持 | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ 已支持 | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (去中心化永续交易所)
|
||||
|
||||
|
||||
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
File diff suppressed because it is too large
Load Diff
23
go.mod
23
go.mod
@@ -12,7 +12,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
@@ -22,10 +21,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
@@ -43,7 +46,12 @@ require (
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -60,15 +68,20 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -80,6 +93,7 @@ require (
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
@@ -89,14 +103,21 @@ require (
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
113
go.sum
113
go.sum
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
@@ -8,14 +10,21 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
@@ -64,10 +73,20 @@ github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo2
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -95,8 +114,10 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -133,10 +154,18 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -147,6 +176,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -162,6 +193,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
@@ -170,20 +203,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
@@ -201,6 +238,7 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -217,12 +255,15 @@ github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -231,6 +272,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -247,53 +289,120 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
124
kernel/engine.go
124
kernel/engine.go
@@ -1,6 +1,7 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -8,6 +9,7 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
@@ -490,6 +492,44 @@ 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)
|
||||
@@ -524,6 +564,28 @@ 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 {
|
||||
@@ -640,6 +702,52 @@ 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
|
||||
// ============================================================================
|
||||
@@ -1350,6 +1458,8 @@ 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":
|
||||
@@ -1358,6 +1468,10 @@ 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 {
|
||||
@@ -1369,6 +1483,12 @@ 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] {
|
||||
@@ -1380,6 +1500,10 @@ 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 ""
|
||||
|
||||
@@ -84,6 +84,9 @@ type GridContext struct {
|
||||
|
||||
// Box indicators (Donchian Channels)
|
||||
BoxData *market.BoxData `json:"box_data,omitempty"`
|
||||
|
||||
// Grid direction (neutral, long, short, long_bias, short_bias)
|
||||
CurrentDirection string `json:"current_direction,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -279,6 +282,20 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
@@ -376,6 +393,20 @@ func buildGridUserPromptEn(ctx *GridContext) string {
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescEn := map[string]string{
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long Bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short Bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescEn[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
|
||||
10
main.go
10
main.go
@@ -11,6 +11,7 @@ import (
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -130,12 +131,21 @@ func main() {
|
||||
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||
|
||||
// Create hot-reload channel for Telegram bot; wire it to the API server
|
||||
// so that POST /api/telegram can trigger a bot restart when the token changes.
|
||||
telegramReloadCh := make(chan struct{}, 1)
|
||||
server.SetTelegramReloadCh(telegramReloadCh)
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
// RemoveTrader removes a trader from memory (does not affect database)
|
||||
// Used to force reload when updating trader configuration
|
||||
// If the trader is running, it will be stopped first
|
||||
@@ -690,9 +689,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "gate":
|
||||
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
|
||||
case "kucoin":
|
||||
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "hyperliquid":
|
||||
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
||||
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
|
||||
case "aster":
|
||||
traderConfig.AsterUser = exchangeCfg.AsterUser
|
||||
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
||||
@@ -703,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||
case "indodax":
|
||||
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
||||
}
|
||||
|
||||
// Set API keys based on AI model (convert EncryptedString to string)
|
||||
|
||||
@@ -31,7 +31,7 @@ var (
|
||||
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
||||
|
||||
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
|
||||
// Map interval string to coinank enum
|
||||
var coinankInterval coinank_enum.Interval
|
||||
switch interval {
|
||||
@@ -67,14 +67,45 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
||||
}
|
||||
|
||||
// Map exchange string to coinank enum
|
||||
var coinankExchange coinank_enum.Exchange
|
||||
switch strings.ToLower(exchange) {
|
||||
case "binance":
|
||||
coinankExchange = coinank_enum.Binance
|
||||
case "bybit":
|
||||
coinankExchange = coinank_enum.Bybit
|
||||
case "okx":
|
||||
coinankExchange = coinank_enum.Okex
|
||||
case "bitget":
|
||||
coinankExchange = coinank_enum.Bitget
|
||||
case "gate":
|
||||
coinankExchange = coinank_enum.Gate
|
||||
case "hyperliquid":
|
||||
coinankExchange = coinank_enum.Hyperliquid
|
||||
case "aster":
|
||||
coinankExchange = coinank_enum.Aster
|
||||
default:
|
||||
// Default to Binance for unknown exchanges
|
||||
coinankExchange = coinank_enum.Binance
|
||||
}
|
||||
|
||||
// Call CoinAnk free/open API (no authentication required)
|
||||
ctx := context.Background()
|
||||
ts := time.Now().UnixMilli()
|
||||
// Use "To" side to search backward from current time (get historical klines)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
// If exchange-specific data fails, fallback to Binance
|
||||
if coinankExchange != coinank_enum.Binance {
|
||||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
klines := make([]Kline, len(coinankKlines))
|
||||
@@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// Get retrieves market data for the specified token
|
||||
// Get retrieves market data for the specified token (uses Binance data by default)
|
||||
func Get(symbol string) (*Data, error) {
|
||||
return GetWithExchange(symbol, "binance")
|
||||
}
|
||||
|
||||
// GetWithExchange retrieves market data for the specified token using exchange-specific data
|
||||
func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||||
var klines3m, klines4h []Kline
|
||||
var err error
|
||||
// Normalize symbol
|
||||
@@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) {
|
||||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||||
isXyzAsset := IsXyzDexAsset(symbol)
|
||||
|
||||
// For hyperliquid exchange, also use Hyperliquid API
|
||||
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
|
||||
|
||||
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
||||
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +205,15 @@ func Get(symbol string) (*Data, error) {
|
||||
}
|
||||
|
||||
// Get 4-hour K-line data
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +329,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
|
||||
// Use CoinAnk for regular crypto assets (default to Binance)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||
continue
|
||||
@@ -1068,6 +1107,11 @@ func Normalize(symbol string) string {
|
||||
return "xyz:" + base
|
||||
}
|
||||
|
||||
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||||
symbol = strings.ReplaceAll(symbol, "_", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-", "")
|
||||
|
||||
// For regular crypto assets
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
return symbol
|
||||
@@ -1283,7 +1327,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
|
||||
if IsXyzDexAsset(symbol) {
|
||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||
} else {
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -226,3 +226,37 @@ const (
|
||||
BreakoutMid BreakoutLevel = "mid"
|
||||
BreakoutLong BreakoutLevel = "long"
|
||||
)
|
||||
|
||||
// GridDirection represents the current grid trading direction bias
|
||||
type GridDirection string
|
||||
|
||||
const (
|
||||
GridDirectionNeutral GridDirection = "neutral" // 50% buy + 50% sell
|
||||
GridDirectionLong GridDirection = "long" // 100% buy
|
||||
GridDirectionShort GridDirection = "short" // 100% sell
|
||||
GridDirectionLongBias GridDirection = "long_bias" // 70% buy + 30% sell (default)
|
||||
GridDirectionShortBias GridDirection = "short_bias" // 30% buy + 70% sell (default)
|
||||
)
|
||||
|
||||
// GetBuySellRatio returns the buy and sell ratio for this direction
|
||||
// biasRatio is the ratio for biased directions (default 0.7 means 70%/30%)
|
||||
func (d GridDirection) GetBuySellRatio(biasRatio float64) (buyRatio, sellRatio float64) {
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7 // Default 70%/30%
|
||||
}
|
||||
|
||||
switch d {
|
||||
case GridDirectionNeutral:
|
||||
return 0.5, 0.5
|
||||
case GridDirectionLong:
|
||||
return 1.0, 0.0
|
||||
case GridDirectionShort:
|
||||
return 0.0, 1.0
|
||||
case GridDirectionLongBias:
|
||||
return biasRatio, 1.0 - biasRatio
|
||||
case GridDirectionShortBias:
|
||||
return 1.0 - biasRatio, biasRatio
|
||||
default:
|
||||
return 0.5, 0.5
|
||||
}
|
||||
}
|
||||
|
||||
345
mcp/blockrun_base.go
Normal file
345
mcp/blockrun_base.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderBlockRunBase = "blockrun-base"
|
||||
DefaultBlockRunBaseURL = "https://blockrun.ai"
|
||||
DefaultBlockRunModel = "gpt-5.4"
|
||||
BlockRunChatEndpoint = "/api/v1/chat/completions"
|
||||
BaseUSDCContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
||||
BaseChainID int64 = 8453
|
||||
BaseNetwork = "eip155:8453"
|
||||
)
|
||||
|
||||
// EIP-712 type hashes for USDC TransferWithAuthorization (ERC-3009)
|
||||
var (
|
||||
eip712DomainTypeHash = keccak256String("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
|
||||
transferWithAuthTypeHash = keccak256String("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
|
||||
)
|
||||
|
||||
func keccak256String(s string) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write([]byte(s))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func keccak256Bytes(data ...[]byte) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
for _, b := range data {
|
||||
h.Write(b)
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// BlockRunBaseClient implements AIClient using BlockRun's API with x402 v2 EIP-712 payment signing.
|
||||
type BlockRunBaseClient struct {
|
||||
*Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewBlockRunBaseClient creates a BlockRun Base wallet client (backward compatible).
|
||||
func NewBlockRunBaseClient() AIClient {
|
||||
return NewBlockRunBaseClientWithOptions()
|
||||
}
|
||||
|
||||
// NewBlockRunBaseClientWithOptions creates a BlockRun Base wallet client.
|
||||
func NewBlockRunBaseClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderBlockRunBase),
|
||||
WithModel(DefaultBlockRunModel),
|
||||
WithBaseURL(DefaultBlockRunBaseURL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||
|
||||
c := &BlockRunBaseClient{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the EVM private key (hex, with or without 0x prefix).
|
||||
// customModel selects the AI model to use (e.g. "claude-sonnet-4.6"); empty means default.
|
||||
func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
hexKey := strings.TrimPrefix(apiKey, "0x")
|
||||
privKey, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] BlockRun Base: invalid private key: %v", err)
|
||||
} else {
|
||||
c.privateKey = privKey
|
||||
c.APIKey = apiKey
|
||||
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base wallet: %s", addr)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", DefaultBlockRunModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
|
||||
}
|
||||
|
||||
// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value.
|
||||
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
|
||||
}
|
||||
|
||||
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
|
||||
// Used by both BlockRunBaseClient and Claw402Client.
|
||||
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
network := opt.Network
|
||||
asset := opt.Asset
|
||||
extra := opt.Extra
|
||||
maxTimeout := opt.MaxTimeoutSeconds
|
||||
if maxTimeout == 0 {
|
||||
maxTimeout = 300
|
||||
}
|
||||
|
||||
resourceURL := ""
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if resource != nil {
|
||||
resourceURL = resource.URL
|
||||
resourceDesc = resource.Description
|
||||
resourceMime = resource.MimeType
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
validAfter := int64(0)
|
||||
validBefore := now + int64(maxTimeout)
|
||||
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := "0x" + hex.EncodeToString(nonceBytes)
|
||||
|
||||
domainName := "USD Coin"
|
||||
domainVersion := "2"
|
||||
if extra != nil {
|
||||
if v, ok := extra["name"]; ok && v != "" {
|
||||
domainName = v
|
||||
}
|
||||
if v, ok := extra["version"]; ok && v != "" {
|
||||
domainVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
domainSeparator, err := buildDomainSeparatorDynamic(domainName, domainVersion, network, asset)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build domain separator: %w", err)
|
||||
}
|
||||
|
||||
amountBig, err := parseBigInt(amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid amount: %w", err)
|
||||
}
|
||||
|
||||
structHash, err := buildTransferWithAuthHashDynamic(senderAddr, recipient, amountBig, validAfter, validBefore, nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build struct hash: %w", err)
|
||||
}
|
||||
|
||||
digest := make([]byte, 0, 66)
|
||||
digest = append(digest, 0x19, 0x01)
|
||||
digest = append(digest, domainSeparator...)
|
||||
digest = append(digest, structHash...)
|
||||
hash := keccak256Bytes(digest)
|
||||
|
||||
sig, err := crypto.Sign(hash, privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign: %w", err)
|
||||
}
|
||||
if sig[64] < 27 {
|
||||
sig[64] += 27
|
||||
}
|
||||
|
||||
sigHex := "0x" + hex.EncodeToString(sig)
|
||||
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
"url": resourceURL,
|
||||
"description": resourceDesc,
|
||||
"mimeType": resourceMime,
|
||||
},
|
||||
"accepted": map[string]interface{}{
|
||||
"scheme": "exact",
|
||||
"network": network,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"payTo": recipient,
|
||||
"maxTimeoutSeconds": maxTimeout,
|
||||
"extra": extra,
|
||||
},
|
||||
"payload": map[string]interface{}{
|
||||
"signature": sigHex,
|
||||
"authorization": map[string]string{
|
||||
"from": senderAddr,
|
||||
"to": recipient,
|
||||
"value": amount,
|
||||
"validAfter": fmt.Sprintf("%d", validAfter),
|
||||
"validBefore": fmt.Sprintf("%d", validBefore),
|
||||
"nonce": nonce,
|
||||
},
|
||||
},
|
||||
"extensions": map[string]interface{}{},
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(paymentData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payment result: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(resultJSON), nil
|
||||
}
|
||||
|
||||
// buildDomainSeparatorDynamic builds the EIP-712 domain separator using runtime values.
|
||||
func buildDomainSeparatorDynamic(name, version, network, asset string) ([]byte, error) {
|
||||
// Extract chain ID from network string like "eip155:8453"
|
||||
chainID := new(big.Int).SetInt64(BaseChainID)
|
||||
if strings.HasPrefix(network, "eip155:") {
|
||||
parts := strings.SplitN(network, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
if n, ok := new(big.Int).SetString(parts[1], 10); ok {
|
||||
chainID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contractAddr, err := hex.DecodeString(strings.TrimPrefix(asset, "0x"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid contract address: %w", err)
|
||||
}
|
||||
|
||||
nameHash := keccak256String(name)
|
||||
versionHash := keccak256String(version)
|
||||
|
||||
encoded := make([]byte, 0, 5*32)
|
||||
encoded = append(encoded, leftPad32(eip712DomainTypeHash)...)
|
||||
encoded = append(encoded, leftPad32(nameHash)...)
|
||||
encoded = append(encoded, leftPad32(versionHash)...)
|
||||
encoded = append(encoded, leftPad32(chainID.Bytes())...)
|
||||
addrPadded := make([]byte, 32)
|
||||
copy(addrPadded[32-len(contractAddr):], contractAddr)
|
||||
encoded = append(encoded, addrPadded...)
|
||||
|
||||
return keccak256Bytes(encoded), nil
|
||||
}
|
||||
|
||||
// buildTransferWithAuthHashDynamic builds the struct hash for TransferWithAuthorization.
|
||||
func buildTransferWithAuthHashDynamic(from, to string, value *big.Int, validAfter, validBefore int64, nonce string) ([]byte, error) {
|
||||
fromBytes, err := hexToAddress(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid from address: %w", err)
|
||||
}
|
||||
toBytes, err := hexToAddress(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid to address: %w", err)
|
||||
}
|
||||
nonceBytes, err := hexToBytes32(nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid nonce: %w", err)
|
||||
}
|
||||
|
||||
validAfterBig := new(big.Int).SetInt64(validAfter)
|
||||
validBeforeBig := new(big.Int).SetInt64(validBefore)
|
||||
|
||||
encoded := make([]byte, 0, 7*32)
|
||||
encoded = append(encoded, leftPad32(transferWithAuthTypeHash)...)
|
||||
encoded = append(encoded, leftPad32(fromBytes)...)
|
||||
encoded = append(encoded, leftPad32(toBytes)...)
|
||||
encoded = append(encoded, leftPad32(value.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(validAfterBig.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(validBeforeBig.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(nonceBytes)...)
|
||||
|
||||
return keccak256Bytes(encoded), nil
|
||||
}
|
||||
|
||||
func hexToAddress(s string) ([]byte, error) {
|
||||
s = strings.TrimPrefix(s, "0x")
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != 20 {
|
||||
return nil, fmt.Errorf("address must be 20 bytes, got %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func hexToBytes32(s string) ([]byte, error) {
|
||||
s = strings.TrimPrefix(s, "0x")
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 32 {
|
||||
return nil, fmt.Errorf("nonce too long: %d bytes", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func parseBigInt(s string) (*big.Int, error) {
|
||||
n := new(big.Int)
|
||||
// Only treat as hex when explicitly prefixed with 0x/0X.
|
||||
// x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC).
|
||||
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||
if _, ok := n.SetString(s[2:], 16); ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse hex big.Int from %q", s)
|
||||
}
|
||||
if _, ok := n.SetString(s, 10); ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse big.Int from %q", s)
|
||||
}
|
||||
|
||||
// leftPad32 pads a byte slice to 32 bytes on the left (ABI encoding).
|
||||
func leftPad32(b []byte) []byte {
|
||||
if len(b) >= 32 {
|
||||
return b[:32]
|
||||
}
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[32-len(b):], b)
|
||||
return padded
|
||||
}
|
||||
|
||||
// buildUrl returns the full BlockRun endpoint URL.
|
||||
func (c *BlockRunBaseClient) buildUrl() string {
|
||||
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
277
mcp/blockrun_sol.go
Normal file
277
mcp/blockrun_sol.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gagliardetto/solana-go"
|
||||
"github.com/gagliardetto/solana-go/programs/compute-budget"
|
||||
"github.com/gagliardetto/solana-go/programs/token"
|
||||
"github.com/gagliardetto/solana-go/rpc"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderBlockRunSol = "blockrun-sol"
|
||||
DefaultBlockRunSolURL = "https://sol.blockrun.ai"
|
||||
SolanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
||||
SolanaNetwork = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
|
||||
SolanaMainnetRPC = "https://api.mainnet-beta.solana.com"
|
||||
|
||||
// Compute budget defaults (match @x402/svm)
|
||||
computeUnitLimit = uint32(8000)
|
||||
computeUnitPrice = uint64(1)
|
||||
)
|
||||
|
||||
// BlockRunSolClient implements AIClient using BlockRun's Solana x402 v2 payment protocol.
|
||||
type BlockRunSolClient struct {
|
||||
*Client
|
||||
keypair solana.PrivateKey
|
||||
}
|
||||
|
||||
// NewBlockRunSolClient creates a BlockRun Solana wallet client (backward compatible).
|
||||
func NewBlockRunSolClient() AIClient {
|
||||
return NewBlockRunSolClientWithOptions()
|
||||
}
|
||||
|
||||
// NewBlockRunSolClientWithOptions creates a BlockRun Solana wallet client.
|
||||
func NewBlockRunSolClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderBlockRunSol),
|
||||
WithModel(DefaultBlockRunModel),
|
||||
WithBaseURL(DefaultBlockRunSolURL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
|
||||
c := &BlockRunSolClient{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the Solana wallet private key (base58-encoded 64-byte keypair).
|
||||
// customModel selects the AI model; empty means default.
|
||||
func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
kp, err := solana.PrivateKeyFromBase58(strings.TrimSpace(apiKey))
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] BlockRun Sol: failed to parse private key: %v", err)
|
||||
return
|
||||
}
|
||||
c.keypair = kp
|
||||
c.APIKey = apiKey
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol wallet: %s", kp.PublicKey().String())
|
||||
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", DefaultBlockRunModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
|
||||
}
|
||||
|
||||
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
|
||||
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
|
||||
if c.keypair == nil {
|
||||
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
|
||||
}
|
||||
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
|
||||
}
|
||||
|
||||
// Find the Solana option
|
||||
var opt *x402AcceptOption
|
||||
for i := range req.Accepts {
|
||||
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
|
||||
opt = &req.Accepts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if opt == nil {
|
||||
return "", fmt.Errorf("no Solana payment option in x402 response")
|
||||
}
|
||||
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
feePayer := ""
|
||||
if opt.Extra != nil {
|
||||
feePayer = opt.Extra["feePayer"]
|
||||
}
|
||||
if feePayer == "" {
|
||||
return "", fmt.Errorf("feePayer missing from Solana x402 extra")
|
||||
}
|
||||
|
||||
maxTimeout := opt.MaxTimeoutSeconds
|
||||
if maxTimeout == 0 {
|
||||
maxTimeout = 300
|
||||
}
|
||||
|
||||
resourceURL := DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if req.Resource != nil {
|
||||
resourceURL = req.Resource.URL
|
||||
resourceDesc = req.Resource.Description
|
||||
resourceMime = req.Resource.MimeType
|
||||
}
|
||||
|
||||
// Build the SPL TransferChecked transaction
|
||||
txB64, err := c.buildSolanaTransferTx(recipient, feePayer, amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build Solana transfer tx: %w", err)
|
||||
}
|
||||
|
||||
// Build x402 v2 payment payload
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
"url": resourceURL,
|
||||
"description": resourceDesc,
|
||||
"mimeType": resourceMime,
|
||||
},
|
||||
"accepted": map[string]interface{}{
|
||||
"scheme": "exact",
|
||||
"network": SolanaNetwork,
|
||||
"amount": amount,
|
||||
"asset": SolanaUSDCMint,
|
||||
"payTo": recipient,
|
||||
"maxTimeoutSeconds": maxTimeout,
|
||||
"extra": opt.Extra,
|
||||
},
|
||||
"payload": map[string]string{
|
||||
"transaction": txB64,
|
||||
},
|
||||
"extensions": map[string]interface{}{},
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(paymentData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal Solana payment: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(resultJSON), nil
|
||||
}
|
||||
|
||||
// buildSolanaTransferTx builds a partial-signed VersionedTransaction for SPL USDC TransferChecked.
|
||||
// The fee payer (CDP facilitator) slot is left with a zero signature; only the user signs.
|
||||
func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr string) (string, error) {
|
||||
ownerPubkey := c.keypair.PublicKey()
|
||||
|
||||
// Parse recipient and feePayer
|
||||
recipientPK, err := solana.PublicKeyFromBase58(recipient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid recipient address: %w", err)
|
||||
}
|
||||
feePayerPK, err := solana.PublicKeyFromBase58(feePayer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid feePayer address: %w", err)
|
||||
}
|
||||
mintPK := solana.MustPublicKeyFromBase58(SolanaUSDCMint)
|
||||
|
||||
// Parse amount
|
||||
var amountU64 uint64
|
||||
if _, err := fmt.Sscanf(amountStr, "%d", &amountU64); err != nil {
|
||||
return "", fmt.Errorf("invalid amount %q: %w", amountStr, err)
|
||||
}
|
||||
|
||||
// Derive ATAs
|
||||
sourceATA, _, err := solana.FindAssociatedTokenAddress(ownerPubkey, mintPK)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive source ATA: %w", err)
|
||||
}
|
||||
destATA, _, err := solana.FindAssociatedTokenAddress(recipientPK, mintPK)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive dest ATA: %w", err)
|
||||
}
|
||||
|
||||
// Fetch latest blockhash from Solana mainnet
|
||||
rpcClient := rpc.New(SolanaMainnetRPC)
|
||||
bhResp, err := rpcClient.GetLatestBlockhash(context.Background(), rpc.CommitmentFinalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch blockhash: %w", err)
|
||||
}
|
||||
recentBlockhash := bhResp.Value.Blockhash
|
||||
|
||||
// Build instructions: ComputeBudgetSetLimit, ComputeBudgetSetPrice, TransferChecked
|
||||
setLimitIx, err := computebudget.NewSetComputeUnitLimitInstruction(computeUnitLimit).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build SetComputeUnitLimit: %w", err)
|
||||
}
|
||||
setPriceIx, err := computebudget.NewSetComputeUnitPriceInstruction(computeUnitPrice).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build SetComputeUnitPrice: %w", err)
|
||||
}
|
||||
transferIx, err := token.NewTransferCheckedInstruction(
|
||||
amountU64,
|
||||
6, // USDC decimals
|
||||
sourceATA,
|
||||
mintPK,
|
||||
destATA,
|
||||
ownerPubkey,
|
||||
[]solana.PublicKey{},
|
||||
).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build TransferChecked: %w", err)
|
||||
}
|
||||
|
||||
// Build transaction with feePayer as payer (matches Python SDK)
|
||||
tx, err := solana.NewTransaction(
|
||||
[]solana.Instruction{setLimitIx, setPriceIx, transferIx},
|
||||
recentBlockhash,
|
||||
solana.TransactionPayer(feePayerPK),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build transaction: %w", err)
|
||||
}
|
||||
|
||||
// Partial sign: user signs; fee_payer (CDP) co-signs on server side
|
||||
// The transaction has 2 signers: [feePayer (index 0), owner (index 1)]
|
||||
// We sign only our index (owner).
|
||||
_, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
|
||||
if key.Equals(ownerPubkey) {
|
||||
return &c.keypair
|
||||
}
|
||||
return nil // feePayer will be signed by BlockRun CDP
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign transaction: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := tx.MarshalBinary()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(txBytes), nil
|
||||
}
|
||||
|
||||
// buildUrl returns the full BlockRun Solana endpoint URL.
|
||||
func (c *BlockRunSolClient) buildUrl() string {
|
||||
return DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
// Package mcp — ClaudeClient implements the Anthropic Messages API.
|
||||
//
|
||||
// Wire-format differences from the OpenAI-compatible base Client:
|
||||
//
|
||||
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
|
||||
// │ Concept │ OpenAI format │ Anthropic format │
|
||||
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
|
||||
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
|
||||
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
|
||||
// │ System prompt │ messages[0] role=system │ top-level "system" field │
|
||||
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
|
||||
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
|
||||
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
|
||||
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
|
||||
// │ Max tokens │ max_tokens │ max_tokens (same) │
|
||||
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -9,78 +25,67 @@ import (
|
||||
const (
|
||||
ProviderClaude = "claude"
|
||||
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
||||
DefaultClaudeModel = "claude-opus-4-5-20251101"
|
||||
DefaultClaudeModel = "claude-opus-4-6"
|
||||
)
|
||||
|
||||
// ClaudeClient wraps the base Client and overrides the methods that differ
|
||||
// for the Anthropic Messages API. All other behaviour (retry, timeout,
|
||||
// logging) is inherited unchanged.
|
||||
type ClaudeClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewClaudeClient creates Claude client (backward compatible)
|
||||
// NewClaudeClient creates a ClaudeClient with default settings.
|
||||
func NewClaudeClient() AIClient {
|
||||
return NewClaudeClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
||||
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
|
||||
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create Claude preset options
|
||||
claudeOpts := []ClientOption{
|
||||
baseClient := NewClient(append([]ClientOption{
|
||||
WithProvider(ProviderClaude),
|
||||
WithModel(DefaultClaudeModel),
|
||||
WithBaseURL(DefaultClaudeBaseURL),
|
||||
}, opts...)...).(*Client)
|
||||
|
||||
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...)
|
||||
// ── Hook overrides ────────────────────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
// SetAPIKey stores credentials and optional custom endpoint / model.
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
||||
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
||||
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
||||
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
||||
reqHeaders.Set("x-api-key", c.APIKey)
|
||||
reqHeaders.Set("anthropic-version", "2023-06-01")
|
||||
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
|
||||
func (c *ClaudeClient) setAuthHeader(h http.Header) {
|
||||
h.Set("x-api-key", c.APIKey)
|
||||
h.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// buildUrl Claude uses /messages endpoint
|
||||
// buildUrl targets /messages instead of /chat/completions.
|
||||
func (c *ClaudeClient) buildUrl() string {
|
||||
return fmt.Sprintf("%s/messages", c.BaseURL)
|
||||
}
|
||||
|
||||
// buildMCPRequestBody Claude has different request format
|
||||
// buildMCPRequestBody builds the Anthropic wire format for the simple
|
||||
// CallWithMessages path (no tool support).
|
||||
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
requestBody := map[string]any{
|
||||
return map[string]any{
|
||||
"model": c.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
@@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
}
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// parseMCPResponse Claude has different response format
|
||||
// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages
|
||||
// API wire format. This is the key override that makes tool calling work
|
||||
// correctly with Claude.
|
||||
//
|
||||
// Conversions applied:
|
||||
//
|
||||
// - System messages are lifted to the top-level "system" field.
|
||||
// - Tool definitions: parameters → input_schema, wrapper removed.
|
||||
// - Assistant messages with ToolCalls → content[{type:tool_use,...}].
|
||||
// - Tool result messages (role=tool) → role=user with tool_result blocks.
|
||||
// Consecutive tool results are merged into a single user turn (Anthropic
|
||||
// requires strictly alternating user/assistant turns).
|
||||
// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object.
|
||||
func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// ── 1. Separate system prompt from conversation messages ──────────────────
|
||||
var systemPrompt string
|
||||
var convMsgs []Message
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
systemPrompt = m.Content
|
||||
} else {
|
||||
convMsgs = append(convMsgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Convert messages to Anthropic format ───────────────────────────────
|
||||
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
|
||||
|
||||
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"input_schema": t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 4. Assemble request body ──────────────────────────────────────────────
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
"messages": anthropicMsgs,
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
body["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
// tool_choice: Anthropic uses an object, not a string.
|
||||
switch req.ToolChoice {
|
||||
case "auto":
|
||||
body["tool_choice"] = map[string]any{"type": "auto"}
|
||||
case "any":
|
||||
body["tool_choice"] = map[string]any{"type": "any"}
|
||||
case "none", "":
|
||||
// omit — no tool_choice sent
|
||||
}
|
||||
|
||||
if req.Temperature != nil {
|
||||
body["temperature"] = *req.Temperature
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
|
||||
// slice to Anthropic's messages array.
|
||||
//
|
||||
// Rules:
|
||||
// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...]
|
||||
// 2. role=tool (result) → role=user, content=[tool_result, ...]
|
||||
// Consecutive tool-result messages are merged into one user turn so the
|
||||
// conversation always alternates user/assistant.
|
||||
// 3. All other messages → {role, content} as-is.
|
||||
func convertMessagesToAnthropic(msgs []Message) []map[string]any {
|
||||
var out []map[string]any
|
||||
|
||||
for i := 0; i < len(msgs); {
|
||||
msg := msgs[i]
|
||||
|
||||
switch {
|
||||
// ── Assistant message carrying tool calls ─────────────────────────────
|
||||
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
|
||||
var blocks []map[string]any
|
||||
for _, tc := range msg.ToolCalls {
|
||||
// Arguments are a JSON string; Claude wants a parsed object.
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{"_raw": tc.Function.Arguments}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": blocks,
|
||||
})
|
||||
i++
|
||||
|
||||
// ── Tool result message(s) → single user turn ─────────────────────────
|
||||
case msg.Role == "tool":
|
||||
// Collect all consecutive tool-result messages.
|
||||
var blocks []map[string]any
|
||||
for i < len(msgs) && msgs[i].Role == "tool" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msgs[i].ToolCallID,
|
||||
"content": msgs[i].Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "user",
|
||||
"content": blocks,
|
||||
})
|
||||
|
||||
// ── Regular user / assistant text message ─────────────────────────────
|
||||
default:
|
||||
out = append(out, map[string]any{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Response parsers ──────────────────────────────────────────────────────────
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
|
||||
// Used by CallWithMessages / CallWithRequest (no tool support).
|
||||
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
var response struct {
|
||||
r, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls from an Anthropic
|
||||
// response envelope.
|
||||
//
|
||||
// Anthropic response shape:
|
||||
//
|
||||
// {
|
||||
// "content": [
|
||||
// {"type": "text", "text": "..."},
|
||||
// {"type": "tool_use", "id": "...", "name": "...", "input": {...}}
|
||||
// ],
|
||||
// "stop_reason": "tool_use" | "end_turn"
|
||||
// }
|
||||
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var raw struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body)
|
||||
}
|
||||
if raw.Error != nil {
|
||||
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
||||
}
|
||||
|
||||
if len(response.Content) == 0 {
|
||||
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
||||
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && total > 0 {
|
||||
TokenUsageCallback(TokenUsage{
|
||||
Provider: c.Provider,
|
||||
Model: c.Model,
|
||||
PromptTokens: response.Usage.InputTokens,
|
||||
CompletionTokens: response.Usage.OutputTokens,
|
||||
TotalTokens: totalTokens,
|
||||
PromptTokens: raw.Usage.InputTokens,
|
||||
CompletionTokens: raw.Usage.OutputTokens,
|
||||
TotalTokens: total,
|
||||
})
|
||||
}
|
||||
|
||||
// Find text content
|
||||
for _, content := range response.Content {
|
||||
if content.Type == "text" {
|
||||
return content.Text, nil
|
||||
}
|
||||
}
|
||||
result := &LLMResponse{}
|
||||
for _, block := range raw.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
result.Content = block.Text
|
||||
|
||||
return "", fmt.Errorf("no text content in Claude response")
|
||||
case "tool_use":
|
||||
// Input is a JSON object; serialise back to a JSON string so it
|
||||
// matches the ToolCallFunction.Arguments field (always a string).
|
||||
argsJSON, err := json.Marshal(block.Input)
|
||||
if err != nil {
|
||||
argsJSON = []byte("{}")
|
||||
}
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
248
mcp/claude_client_test.go
Normal file
248
mcp/claude_client_test.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── buildRequestBodyFromRequest ────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_SystemPromptLifted(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
},
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
if body["system"] != "You are helpful." {
|
||||
t.Errorf("system not lifted to top level: %v", body["system"])
|
||||
}
|
||||
msgs := body["messages"].([]map[string]any)
|
||||
if len(msgs) != 1 || msgs[0]["role"] != "user" {
|
||||
t.Errorf("system message should be removed from messages array: %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_ToolsUseInputSchema(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
Tools: []Tool{{
|
||||
Type: "function",
|
||||
Function: FunctionDef{
|
||||
Name: "my_tool",
|
||||
Description: "does stuff",
|
||||
Parameters: map[string]any{"type": "object"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
tools, ok := body["tools"].([]map[string]any)
|
||||
if !ok || len(tools) != 1 {
|
||||
t.Fatalf("tools not set correctly: %v", body["tools"])
|
||||
}
|
||||
tool := tools[0]
|
||||
if tool["name"] != "my_tool" {
|
||||
t.Errorf("tool name wrong: %v", tool["name"])
|
||||
}
|
||||
if tool["input_schema"] == nil {
|
||||
t.Error("tool must use input_schema, not parameters")
|
||||
}
|
||||
if _, hasParams := tool["parameters"]; hasParams {
|
||||
t.Error("tool must NOT have parameters key (Anthropic uses input_schema)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildRequestBody_ToolChoiceObject(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
req := &Request{
|
||||
Model: "claude-opus-4-6",
|
||||
Messages: []Message{{Role: "user", Content: "hi"}},
|
||||
ToolChoice: "auto",
|
||||
}
|
||||
body := c.buildRequestBodyFromRequest(req)
|
||||
|
||||
tc, ok := body["tool_choice"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool_choice must be an object, got: %T %v", body["tool_choice"], body["tool_choice"])
|
||||
}
|
||||
if tc["type"] != "auto" {
|
||||
t.Errorf("tool_choice.type must be 'auto', got: %v", tc["type"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── convertMessagesToAnthropic ─────────────────────────────────────────────────
|
||||
|
||||
func TestConvertMessages_AssistantToolCall(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []ToolCall{{
|
||||
ID: "tc1",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{Name: "api_request", Arguments: `{"method":"GET","path":"/api/x","body":{}}`},
|
||||
}},
|
||||
},
|
||||
}
|
||||
out := convertMessagesToAnthropic(msgs)
|
||||
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(out))
|
||||
}
|
||||
msg := out[0]
|
||||
if msg["role"] != "assistant" {
|
||||
t.Errorf("role should be assistant: %v", msg["role"])
|
||||
}
|
||||
blocks := msg["content"].([]map[string]any)
|
||||
if len(blocks) != 1 || blocks[0]["type"] != "tool_use" {
|
||||
t.Errorf("content should be tool_use block: %v", blocks)
|
||||
}
|
||||
if blocks[0]["id"] != "tc1" {
|
||||
t.Errorf("tool_use id wrong: %v", blocks[0]["id"])
|
||||
}
|
||||
// Input must be parsed JSON object, not a string.
|
||||
input, ok := blocks[0]["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("tool_use input must be map, got %T", blocks[0]["input"])
|
||||
}
|
||||
if input["method"] != "GET" {
|
||||
t.Errorf("input.method wrong: %v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMessages_ToolResultMergedIntoUserTurn(t *testing.T) {
|
||||
// Anthropic requires strictly alternating turns; consecutive tool results
|
||||
// must be merged into a single user message.
|
||||
msgs := []Message{
|
||||
{Role: "tool", ToolCallID: "tc1", Content: `{"result":"a"}`},
|
||||
{Role: "tool", ToolCallID: "tc2", Content: `{"result":"b"}`},
|
||||
}
|
||||
out := convertMessagesToAnthropic(msgs)
|
||||
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("consecutive tool results must be merged into one user turn, got %d messages", len(out))
|
||||
}
|
||||
if out[0]["role"] != "user" {
|
||||
t.Errorf("tool results must become role=user: %v", out[0]["role"])
|
||||
}
|
||||
blocks := out[0]["content"].([]map[string]any)
|
||||
if len(blocks) != 2 {
|
||||
t.Errorf("expected 2 tool_result blocks, got %d", len(blocks))
|
||||
}
|
||||
if blocks[0]["type"] != "tool_result" || blocks[1]["type"] != "tool_result" {
|
||||
t.Errorf("blocks should be tool_result: %v", blocks)
|
||||
}
|
||||
if blocks[0]["tool_use_id"] != "tc1" || blocks[1]["tool_use_id"] != "tc2" {
|
||||
t.Errorf("tool_use_id mismatch: %v", blocks)
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseMCPResponseFull ───────────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_ParseResponse_TextOnly(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{
|
||||
"content": [{"type":"text","text":"Hello from Claude"}],
|
||||
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||
}`)
|
||||
resp, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from Claude" {
|
||||
t.Errorf("content mismatch: %q", resp.Content)
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("expected no tool calls: %v", resp.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_ParseResponse_ToolUse(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{
|
||||
"content": [{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01abc",
|
||||
"name": "api_request",
|
||||
"input": {"method":"POST","path":"/api/strategies","body":{"name":"BTC策略"}}
|
||||
}],
|
||||
"usage": {"input_tokens": 100, "output_tokens": 30}
|
||||
}`)
|
||||
resp, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.ID != "toolu_01abc" {
|
||||
t.Errorf("tool call ID wrong: %v", tc.ID)
|
||||
}
|
||||
if tc.Function.Name != "api_request" {
|
||||
t.Errorf("function name wrong: %v", tc.Function.Name)
|
||||
}
|
||||
// Arguments must be a valid JSON string.
|
||||
var args map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
||||
t.Errorf("arguments not valid JSON: %q — %v", tc.Function.Arguments, err)
|
||||
}
|
||||
if args["method"] != "POST" {
|
||||
t.Errorf("args.method wrong: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_ParseResponse_APIError(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
body := []byte(`{"error":{"type":"authentication_error","message":"invalid x-api-key"}}`)
|
||||
_, err := c.parseMCPResponseFull(body)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API error response")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Error("error message should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth header ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestClaudeClient_SetAuthHeader(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
c.APIKey = "sk-ant-test123"
|
||||
|
||||
// net/http.Header canonicalizes keys (x-api-key → X-Api-Key).
|
||||
h := make(http.Header)
|
||||
c.setAuthHeader(h)
|
||||
|
||||
if got := h.Get("x-api-key"); got != "sk-ant-test123" {
|
||||
t.Errorf("x-api-key header not set correctly: %q", got)
|
||||
}
|
||||
if h.Get("anthropic-version") == "" {
|
||||
t.Error("anthropic-version header must be set")
|
||||
}
|
||||
// Must NOT use Authorization: Bearer (that's OpenAI format).
|
||||
if h.Get("Authorization") != "" {
|
||||
t.Error("Claude must use x-api-key, not Authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeClient_BuildUrl(t *testing.T) {
|
||||
c := newTestClaudeClient()
|
||||
url := c.buildUrl()
|
||||
if url != DefaultClaudeBaseURL+"/messages" {
|
||||
t.Errorf("URL should be /messages endpoint, got: %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func newTestClaudeClient() *ClaudeClient {
|
||||
return NewClaudeClientWithOptions().(*ClaudeClient)
|
||||
}
|
||||
166
mcp/claw402.go
Normal file
166
mcp/claw402.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderClaw402 = "claw402"
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "deepseek"
|
||||
)
|
||||
|
||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||
var claw402ModelEndpoints = map[string]string{
|
||||
// OpenAI
|
||||
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
|
||||
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
|
||||
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
|
||||
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
|
||||
// Anthropic
|
||||
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
||||
// DeepSeek
|
||||
"deepseek": "/api/v1/ai/deepseek/chat",
|
||||
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
||||
// Qwen
|
||||
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
||||
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
||||
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
|
||||
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
|
||||
// Grok
|
||||
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
|
||||
// Gemini
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
}
|
||||
|
||||
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
|
||||
// Reuses the same EIP-712 signing as BlockRunBaseClient (same Base chain + USDC contract).
|
||||
// When the selected model routes to an Anthropic endpoint, it automatically uses
|
||||
// the Anthropic wire format for requests and responses (via an internal ClaudeClient).
|
||||
type Claw402Client struct {
|
||||
*Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
claudeProxy *ClaudeClient // non-nil when endpoint is /anthropic/
|
||||
}
|
||||
|
||||
// NewClaw402Client creates a claw402 client (backward compatible).
|
||||
func NewClaw402Client() AIClient {
|
||||
return NewClaw402ClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaw402ClientWithOptions creates a claw402 client with options.
|
||||
func NewClaw402ClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderClaw402),
|
||||
WithModel(DefaultClaw402Model),
|
||||
WithBaseURL(DefaultClaw402URL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
|
||||
|
||||
c := &Claw402Client{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the EVM private key and selects the model endpoint.
|
||||
func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
|
||||
hexKey := strings.TrimPrefix(apiKey, "0x")
|
||||
privKey, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
|
||||
} else {
|
||||
c.privateKey = privKey
|
||||
c.APIKey = apiKey
|
||||
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
||||
c.logger.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
}
|
||||
endpoint := c.resolveEndpoint()
|
||||
c.BaseURL = DefaultClaw402URL + endpoint
|
||||
|
||||
// Anthropic endpoints need different wire format (Messages API)
|
||||
if strings.Contains(endpoint, "/anthropic/") {
|
||||
c.claudeProxy = &ClaudeClient{Client: c.Client}
|
||||
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
|
||||
} else {
|
||||
c.claudeProxy = nil
|
||||
c.logger.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveEndpoint returns the API path for the configured model.
|
||||
func (c *Claw402Client) resolveEndpoint() string {
|
||||
if ep, ok := claw402ModelEndpoints[c.Model]; ok {
|
||||
return ep
|
||||
}
|
||||
// Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4")
|
||||
if strings.HasPrefix(c.Model, "/api/") {
|
||||
return c.Model
|
||||
}
|
||||
return claw402ModelEndpoints[DefaultClaw402Model]
|
||||
}
|
||||
|
||||
func (c *Claw402Client) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *Claw402Client) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signPayment, "Claw402", req)
|
||||
}
|
||||
|
||||
// signPayment signs x402 v2 EIP-712 payment (same Base chain + USDC as BlockRunBase).
|
||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||
}
|
||||
|
||||
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
|
||||
|
||||
func (c *Claw402Client) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
}
|
||||
return c.Client.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.buildRequestBodyFromRequest(req)
|
||||
}
|
||||
return c.Client.buildRequestBodyFromRequest(req)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) parseMCPResponse(body []byte) (string, error) {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.parseMCPResponse(body)
|
||||
}
|
||||
return c.Client.parseMCPResponse(body)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.parseMCPResponseFull(body)
|
||||
}
|
||||
return c.Client.parseMCPResponseFull(body)
|
||||
}
|
||||
|
||||
// buildUrl returns the full claw402 endpoint URL.
|
||||
func (c *Claw402Client) buildUrl() string {
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
func (c *Claw402Client) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
240
mcp/client.go
240
mcp/client.go
@@ -1,7 +1,9 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
|
||||
}
|
||||
|
||||
func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
r, err := client.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull parses the OpenAI-format response body and returns both
|
||||
// the text content and any tool calls.
|
||||
func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
@@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
@@ -264,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
msg := result.Choices[0].Message
|
||||
return &LLMResponse{
|
||||
Content: msg.Content,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) buildUrl() string {
|
||||
@@ -425,50 +442,106 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
|
||||
return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// CallWithRequestFull calls the AI API and returns both text content and tool calls.
|
||||
func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
if client.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
maxRetries := client.config.MaxRetries
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
|
||||
}
|
||||
result, err := client.callWithRequestFull(req)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !client.hooks.isRetryableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// callWithRequestFull single call that returns LLMResponse (content + tool calls).
|
||||
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return client.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
|
||||
// callWithRequest single AI API call (using Request object)
|
||||
func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
// Print current AI configuration
|
||||
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
|
||||
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
|
||||
|
||||
// Build request body (from Request object)
|
||||
requestBody := client.buildRequestBodyFromRequest(req)
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
|
||||
// Serialize request body
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Build URL
|
||||
url := client.hooks.buildUrl()
|
||||
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Send HTTP request
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
result, err := client.hooks.parseMCPResponse(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
||||
@@ -479,13 +552,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
|
||||
// buildRequestBodyFromRequest builds request body from Request object
|
||||
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// Convert Message to API format
|
||||
messages := make([]map[string]string, 0, len(req.Messages))
|
||||
// Convert Message to API format — must use map[string]any to support
|
||||
// tool-call messages (tool_calls, tool_call_id fields).
|
||||
messages := make([]map[string]any, 0, len(req.Messages))
|
||||
for _, msg := range req.Messages {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
m := map[string]any{"role": msg.Role}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
// Assistant message that contains tool invocations.
|
||||
// content must be null/omitted for OpenAI compatibility.
|
||||
m["tool_calls"] = msg.ToolCalls
|
||||
} else if msg.ToolCallID != "" {
|
||||
// Tool result message (role="tool").
|
||||
m["tool_call_id"] = msg.ToolCallID
|
||||
m["content"] = msg.Content
|
||||
} else {
|
||||
m["content"] = msg.Content
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Build basic request body
|
||||
@@ -544,3 +627,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// CallWithRequestStream streams the LLM response via SSE (Server-Sent Events).
|
||||
// onChunk is called with the full accumulated text so far after each received chunk.
|
||||
// Returns the complete final text when the stream ends.
|
||||
//
|
||||
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
|
||||
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
|
||||
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
|
||||
if client.APIKey == "" {
|
||||
return "", fmt.Errorf("AI API key not set")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
req.Stream = true
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
|
||||
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
|
||||
const idleTimeout = 60 * time.Second
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetCh := make(chan struct{}, 1)
|
||||
go func() {
|
||||
t := time.NewTimer(idleTimeout)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cancel() // idle timeout: kill the connection
|
||||
return
|
||||
case <-resetCh:
|
||||
// received a line — reset the idle timer
|
||||
if !t.Stop() {
|
||||
select {
|
||||
case <-t.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
t.Reset(idleTimeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
httpReq = httpReq.WithContext(ctx)
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("streaming request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var accumulated strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
|
||||
for scanner.Scan() {
|
||||
// Ping the watchdog: we received a line, reset the idle timer.
|
||||
select {
|
||||
case resetCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse the SSE JSON chunk
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // skip malformed chunks
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
delta := chunk.Choices[0].Delta.Content
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
accumulated.WriteString(delta)
|
||||
if onChunk != nil {
|
||||
onChunk(accumulated.String())
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
|
||||
}
|
||||
|
||||
return accumulated.String(), nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
)
|
||||
|
||||
// Config client configuration (centralized management of all configurations)
|
||||
@@ -48,7 +49,7 @@ func DefaultConfig() *Config {
|
||||
|
||||
// Default dependencies (use global logger)
|
||||
Logger: logger.NewMCPLogger(),
|
||||
HTTPClient: &http.Client{Timeout: DefaultTimeout},
|
||||
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,52 @@ type AIClient interface {
|
||||
SetAPIKey(apiKey string, customURL string, customModel string)
|
||||
SetTimeout(timeout time.Duration)
|
||||
CallWithMessages(systemPrompt, userPrompt string) (string, error)
|
||||
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
|
||||
CallWithRequest(req *Request) (string, error)
|
||||
// CallWithRequestStream streams the LLM response via SSE.
|
||||
// onChunk is called with the full accumulated text so far (not raw deltas).
|
||||
// Returns the complete final text when done.
|
||||
CallWithRequestStream(req *Request, onChunk func(string)) (string, error)
|
||||
// CallWithRequestFull returns both text content and tool calls.
|
||||
// Use this when the request includes Tools — the LLM may respond with
|
||||
// either a plain text reply (LLMResponse.Content) or tool invocations
|
||||
// (LLMResponse.ToolCalls), but not both.
|
||||
CallWithRequestFull(req *Request) (*LLMResponse, error)
|
||||
}
|
||||
|
||||
// clientHooks internal hook interface (for subclass to override specific steps)
|
||||
// These methods are only used inside the package to implement dynamic dispatch
|
||||
// clientHooks is the internal dispatch interface used to implement per-provider
|
||||
// polymorphism without Go's lack of virtual methods.
|
||||
//
|
||||
// Each method can be overridden by an embedding struct (e.g. ClaudeClient).
|
||||
// The base *Client provides OpenAI-compatible defaults; providers with a
|
||||
// different wire format (Anthropic, Gemini native, etc.) override only what
|
||||
// differs. All call-path methods in client.go invoke these via c.hooks so
|
||||
// that the override is always picked up at runtime.
|
||||
type clientHooks interface {
|
||||
// Hook methods that can be overridden by subclass
|
||||
|
||||
// ── Simple CallWithMessages path ────────────────────────────────────────
|
||||
call(systemPrompt, userPrompt string) (string, error)
|
||||
|
||||
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
||||
|
||||
// ── Shared request plumbing ─────────────────────────────────────────────
|
||||
buildUrl() string
|
||||
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
||||
setAuthHeader(reqHeaders http.Header)
|
||||
marshalRequestBody(requestBody map[string]any) ([]byte, error)
|
||||
|
||||
// ── Advanced (Request-object) path ──────────────────────────────────────
|
||||
// buildRequestBodyFromRequest converts a *Request into the provider's
|
||||
// native wire-format map. Providers that use a different protocol (e.g.
|
||||
// Anthropic uses "input_schema" for tools, "tool_use" content blocks, and
|
||||
// a top-level "system" field) override this method.
|
||||
buildRequestBodyFromRequest(req *Request) map[string]any
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from a non-streaming
|
||||
// response body.
|
||||
parseMCPResponse(body []byte) (string, error)
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls. Providers whose
|
||||
// response envelope differs from the OpenAI choices[] structure (e.g.
|
||||
// Anthropic content[] with tool_use blocks) override this method.
|
||||
parseMCPResponseFull(body []byte) (*LLMResponse, error)
|
||||
|
||||
isRetryableError(err error) bool
|
||||
}
|
||||
|
||||
83
mcp/minimax_client.go
Normal file
83
mcp/minimax_client.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderMiniMax = "minimax"
|
||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
||||
)
|
||||
|
||||
type MiniMaxClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewMiniMaxClient creates MiniMax client (backward compatible)
|
||||
func NewMiniMaxClient() AIClient {
|
||||
return NewMiniMaxClientWithOptions()
|
||||
}
|
||||
|
||||
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
|
||||
//
|
||||
// Usage examples:
|
||||
//
|
||||
// // Basic usage
|
||||
// client := mcp.NewMiniMaxClientWithOptions()
|
||||
//
|
||||
// // Custom configuration
|
||||
// client := mcp.NewMiniMaxClientWithOptions(
|
||||
// mcp.WithAPIKey("sk-xxx"),
|
||||
// mcp.WithLogger(customLogger),
|
||||
// mcp.WithTimeout(60*time.Second),
|
||||
// )
|
||||
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create MiniMax preset options
|
||||
minimaxOpts := []ClientOption{
|
||||
WithProvider(ProviderMiniMax),
|
||||
WithModel(DefaultMiniMaxModel),
|
||||
WithBaseURL(DefaultMiniMaxBaseURL),
|
||||
}
|
||||
|
||||
// 2. Merge user options (user options have higher priority)
|
||||
allOpts := append(minimaxOpts, opts...)
|
||||
|
||||
// 3. Create base client
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
|
||||
// 4. Create MiniMax client
|
||||
minimaxClient := &MiniMaxClient{
|
||||
Client: baseClient,
|
||||
}
|
||||
|
||||
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
|
||||
baseClient.hooks = minimaxClient
|
||||
|
||||
return minimaxClient
|
||||
}
|
||||
|
||||
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// MiniMax uses standard OpenAI-compatible API with Bearer auth
|
||||
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
|
||||
c.Client.setAuthHeader(reqHeaders)
|
||||
}
|
||||
272
mcp/minimax_client_test.go
Normal file
272
mcp/minimax_client_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Test MiniMaxClient Creation and Configuration
|
||||
// ============================================================
|
||||
|
||||
func TestNewMiniMaxClient_Default(t *testing.T) {
|
||||
client := NewMiniMaxClient()
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("client should not be nil")
|
||||
}
|
||||
|
||||
// Type assertion check
|
||||
mmClient, ok := client.(*MiniMaxClient)
|
||||
if !ok {
|
||||
t.Fatal("client should be *MiniMaxClient")
|
||||
}
|
||||
|
||||
// Verify default values
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
|
||||
}
|
||||
|
||||
if mmClient.logger == nil {
|
||||
t.Error("logger should not be nil")
|
||||
}
|
||||
|
||||
if mmClient.httpClient == nil {
|
||||
t.Error("httpClient should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMiniMaxClientWithOptions(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
customAPIKey := "sk-custom-key"
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
WithModel(customModel),
|
||||
WithAPIKey(customAPIKey),
|
||||
WithMaxTokens(4000),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify custom options are applied
|
||||
if mmClient.logger != mockLogger {
|
||||
t.Error("logger should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Error("Model should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.APIKey != customAPIKey {
|
||||
t.Error("APIKey should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.MaxTokens != 4000 {
|
||||
t.Error("MaxTokens should be 4000")
|
||||
}
|
||||
|
||||
// Verify MiniMax default values are retained
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test SetAPIKey
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Test setting API Key (default URL and Model)
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
|
||||
|
||||
if mmClient.APIKey != "sk-test-key-12345678" {
|
||||
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
if len(logs) == 0 {
|
||||
t.Error("should have logged API key setting")
|
||||
}
|
||||
|
||||
// Verify BaseURL and Model remain default
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Error("BaseURL should remain default")
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Error("Model should remain default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customURL := "https://api.minimaxi.com/v1"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
|
||||
|
||||
if mmClient.BaseURL != customURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomURLLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
|
||||
hasCustomURLLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomURLLog {
|
||||
t.Error("should have logged custom BaseURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomModelLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
|
||||
hasCustomModelLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomModelLog {
|
||||
t.Error("should have logged custom Model")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test Integration Features
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetSuccessResponse("MiniMax AI response")
|
||||
mockLogger := NewMockLogger()
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(mockLogger),
|
||||
WithAPIKey("sk-test-key"),
|
||||
)
|
||||
|
||||
result, err := client.CallWithMessages("system prompt", "user prompt")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
|
||||
if result != "MiniMax AI response" {
|
||||
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
|
||||
}
|
||||
|
||||
// Verify request
|
||||
requests := mockHTTP.GetRequests()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", len(requests))
|
||||
}
|
||||
|
||||
req := requests[0]
|
||||
|
||||
// Verify URL
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if req.URL.String() != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
|
||||
}
|
||||
|
||||
// Verify Authorization header
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader != "Bearer sk-test-key" {
|
||||
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
|
||||
}
|
||||
|
||||
// Verify Content-Type
|
||||
if req.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("Content-Type should be application/json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_Timeout(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithTimeout(30 * time.Second),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
if mmClient.httpClient.Timeout != 30*time.Second {
|
||||
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
|
||||
// Test SetTimeout
|
||||
client.SetTimeout(60 * time.Second)
|
||||
|
||||
if mmClient.httpClient.Timeout != 60*time.Second {
|
||||
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test hooks Mechanism
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions()
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify hooks point to mmClient itself (implements polymorphism)
|
||||
if mmClient.hooks != mmClient {
|
||||
t.Error("hooks should point to mmClient for polymorphism")
|
||||
}
|
||||
|
||||
// Verify buildUrl uses MiniMax configuration
|
||||
url := mmClient.buildUrl()
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if url != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
const (
|
||||
ProviderOpenAI = "openai"
|
||||
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
DefaultOpenAIModel = "gpt-5.2"
|
||||
DefaultOpenAIModel = "gpt-5.4"
|
||||
)
|
||||
|
||||
type OpenAIClient struct {
|
||||
|
||||
@@ -22,7 +22,11 @@ func WithLogger(logger Logger) ClientOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets custom HTTP client
|
||||
// WithHTTPClient sets custom HTTP client.
|
||||
//
|
||||
// WARNING: The default client uses security.SafeHTTPClient() with SSRF protection
|
||||
// (blocks private IPs, cloud metadata, validates redirects). Overriding it bypasses
|
||||
// these protections. Only use in tests or with a client providing equivalent safeguards.
|
||||
//
|
||||
// Usage example:
|
||||
// httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
@@ -160,3 +164,17 @@ func WithQwenConfig(apiKey string) ClientOption {
|
||||
c.Model = DefaultQwenModel
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiniMaxConfig sets MiniMax configuration
|
||||
//
|
||||
// Usage example:
|
||||
//
|
||||
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
|
||||
func WithMiniMaxConfig(apiKey string) ClientOption {
|
||||
return func(c *Config) {
|
||||
c.Provider = ProviderMiniMax
|
||||
c.APIKey = apiKey
|
||||
c.BaseURL = DefaultMiniMaxBaseURL
|
||||
c.Model = DefaultMiniMaxModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package mcp
|
||||
|
||||
// Message represents a conversation message
|
||||
// Message represents a conversation message.
|
||||
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
|
||||
// and tool result messages (Role="tool", ToolCallID, Content).
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system", "user", "assistant"
|
||||
Content string `json:"content"` // Message content
|
||||
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||
Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages
|
||||
}
|
||||
|
||||
// ToolCall is a single function call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // Unique call ID (e.g. "call_abc123")
|
||||
Type string `json:"type"` // Always "function"
|
||||
Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments
|
||||
}
|
||||
|
||||
// ToolCallFunction holds the function name and raw JSON arguments string.
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"` // Function name
|
||||
Arguments string `json:"arguments"` // JSON-encoded argument object
|
||||
}
|
||||
|
||||
// LLMResponse is returned by CallWithRequestFull and carries both the assistant
|
||||
// text reply (Content) and any structured tool calls (ToolCalls).
|
||||
// Exactly one of the two fields will be non-empty for a well-formed response.
|
||||
type LLMResponse struct {
|
||||
Content string // Plain-text reply (final answer)
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that AI can call
|
||||
|
||||
219
mcp/x402.go
Normal file
219
mcp/x402.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// ── Shared x402 types ────────────────────────────────────────────────────────
|
||||
|
||||
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
|
||||
type x402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []x402AcceptOption `json:"accepts"`
|
||||
Resource *x402Resource `json:"resource"`
|
||||
}
|
||||
|
||||
// x402AcceptOption is a payment option from the x402 v2 header.
|
||||
type x402AcceptOption struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Network string `json:"network"`
|
||||
Amount string `json:"amount"`
|
||||
Asset string `json:"asset"`
|
||||
PayTo string `json:"payTo"`
|
||||
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
// x402Resource describes the resource being paid for.
|
||||
type x402Resource struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
|
||||
// x402SignFunc is a callback that signs an x402 payment header and returns the
|
||||
// base64-encoded payment signature.
|
||||
type x402SignFunc func(paymentHeaderB64 string) (string, error)
|
||||
|
||||
// ── Shared x402 helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
|
||||
// trying RawStdEncoding first then StdEncoding as fallback.
|
||||
func x402DecodeHeader(b64 string) ([]byte, error) {
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64-decode payment header: %w", err)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
|
||||
// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402.
|
||||
func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("no private key set for %s wallet", providerName)
|
||||
}
|
||||
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
|
||||
}
|
||||
if len(req.Accepts) == 0 {
|
||||
return "", fmt.Errorf("no payment options in x402 response")
|
||||
}
|
||||
|
||||
senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
|
||||
}
|
||||
|
||||
// doX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
||||
// On a 402 response it reads the Payment-Required (or X-Payment-Required) header,
|
||||
// signs via signFn, retries with Payment-Signature, and logs the Payment-Response
|
||||
// header (tx hash) on success.
|
||||
func doX402Request(
|
||||
httpClient *http.Client,
|
||||
buildReqFn func() (*http.Request, error),
|
||||
signFn x402SignFunc,
|
||||
providerTag string,
|
||||
logger Logger,
|
||||
) ([]byte, error) {
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
paymentHeader := resp.Header.Get("Payment-Required")
|
||||
if paymentHeader == "" {
|
||||
paymentHeader = resp.Header.Get("X-Payment-Required")
|
||||
}
|
||||
if paymentHeader == "" {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body))
|
||||
}
|
||||
|
||||
// Drain 402 body to allow HTTP connection reuse.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
paymentSig, err := signFn(paymentHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign x402 payment: %w", err)
|
||||
}
|
||||
|
||||
req2, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
req2.Header.Set("Payment-Signature", paymentSig)
|
||||
|
||||
resp2, err := httpClient.Do(req2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read payment retry response: %w", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2))
|
||||
}
|
||||
|
||||
if txHash := resp2.Header.Get("Payment-Response"); txHash != "" {
|
||||
logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash)
|
||||
}
|
||||
|
||||
return body2, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// x402BuildRequest creates a POST request with Content-Type but no auth header.
|
||||
func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
|
||||
func x402SetAuthHeader(_ http.Header) {}
|
||||
|
||||
// x402Call handles the x402 payment flow for the simple CallWithMessages path.
|
||||
func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
|
||||
c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body)
|
||||
}
|
||||
|
||||
// x402CallFull handles the x402 payment flow for the advanced Request path.
|
||||
func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) {
|
||||
if c.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = c.Model
|
||||
}
|
||||
|
||||
c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
@@ -15,11 +15,18 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
# index.html — never cache (so new deploys take effect immediately)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Frontend routes (SPA) with static asset caching
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
# Cache hashed static assets (js/css have content hashes in filenames)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
223
provider/hyperliquid/coins.go
Normal file
223
provider/hyperliquid/coins.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
||||
cacheDuration = 24 * time.Hour // Cache for 24 hours
|
||||
)
|
||||
|
||||
// CoinInfo represents basic coin information
|
||||
type CoinInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
|
||||
}
|
||||
|
||||
// CoinProvider provides Hyperliquid coin lists
|
||||
type CoinProvider struct {
|
||||
mu sync.RWMutex
|
||||
allCoins []CoinInfo
|
||||
mainCoins []CoinInfo
|
||||
lastUpdated time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
defaultProvider *CoinProvider
|
||||
providerOnce sync.Once
|
||||
)
|
||||
|
||||
// GetProvider returns the singleton CoinProvider instance
|
||||
func GetProvider() *CoinProvider {
|
||||
providerOnce.Do(func() {
|
||||
defaultProvider = &CoinProvider{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
})
|
||||
return defaultProvider
|
||||
}
|
||||
|
||||
// metaResponse represents the response from Hyperliquid meta endpoint
|
||||
type metaResponse struct {
|
||||
Universe []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"universe"`
|
||||
}
|
||||
|
||||
// assetCtx represents asset context with volume data
|
||||
type assetCtx struct {
|
||||
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
|
||||
}
|
||||
|
||||
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
|
||||
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
||||
// Request metaAndAssetCtxs to get both coin names and volume data
|
||||
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch coin data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Response is an array: [meta, [assetCtxs...]]
|
||||
var rawResp []json.RawMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(rawResp) < 2 {
|
||||
return fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
// Parse meta
|
||||
var meta metaResponse
|
||||
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
|
||||
return fmt.Errorf("failed to parse meta: %w", err)
|
||||
}
|
||||
|
||||
// Parse asset contexts
|
||||
var ctxs []assetCtx
|
||||
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
|
||||
return fmt.Errorf("failed to parse asset contexts: %w", err)
|
||||
}
|
||||
|
||||
// Build coin list with volume
|
||||
var coins []CoinInfo
|
||||
for i, u := range meta.Universe {
|
||||
var vol float64
|
||||
if i < len(ctxs) {
|
||||
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
|
||||
}
|
||||
coins = append(coins, CoinInfo{
|
||||
Symbol: u.Name,
|
||||
Volume24h: vol,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by volume descending
|
||||
sort.Slice(coins, func(i, j int) bool {
|
||||
return coins[i].Volume24h > coins[j].Volume24h
|
||||
})
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.allCoins = coins
|
||||
// Main coins are top 20 by volume
|
||||
if len(coins) > 20 {
|
||||
p.mainCoins = coins[:20]
|
||||
} else {
|
||||
p.mainCoins = coins
|
||||
}
|
||||
p.lastUpdated = time.Now()
|
||||
|
||||
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureUpdated checks if cache is stale and refreshes if needed
|
||||
func (p *CoinProvider) ensureUpdated(ctx context.Context) error {
|
||||
p.mu.RLock()
|
||||
needsUpdate := time.Since(p.lastUpdated) > cacheDuration || len(p.allCoins) == 0
|
||||
p.mu.RUnlock()
|
||||
|
||||
if needsUpdate {
|
||||
return p.fetchCoins(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllCoins returns all available Hyperliquid perp coins
|
||||
func (p *CoinProvider) GetAllCoins(ctx context.Context) ([]CoinInfo, error) {
|
||||
if err := p.ensureUpdated(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid mutation
|
||||
result := make([]CoinInfo, len(p.allCoins))
|
||||
copy(result, p.allCoins)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMainCoins returns top N coins by 24h volume
|
||||
func (p *CoinProvider) GetMainCoins(ctx context.Context, limit int) ([]CoinInfo, error) {
|
||||
if err := p.ensureUpdated(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
// Return top N coins
|
||||
count := limit
|
||||
if count > len(p.allCoins) {
|
||||
count = len(p.allCoins)
|
||||
}
|
||||
|
||||
result := make([]CoinInfo, count)
|
||||
copy(result, p.allCoins[:count])
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetCoinSymbols returns just the symbol names (for compatibility)
|
||||
func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
|
||||
coins, err := GetProvider().GetAllCoins(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetMainCoinSymbols returns top N coin symbols by volume
|
||||
func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
|
||||
coins, err := GetProvider().GetMainCoins(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// ForceRefresh forces a refresh of the coin cache
|
||||
func (p *CoinProvider) ForceRefresh(ctx context.Context) error {
|
||||
return p.fetchCoins(ctx)
|
||||
}
|
||||
@@ -105,7 +105,8 @@ func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
@@ -147,10 +148,7 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
|
||||
183
start.sh
183
start.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# NOFX AI Trading System - Docker Quick Start Script
|
||||
# NOFX AI Trading System - Docker Management Script
|
||||
# Usage: ./start.sh [command]
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -45,10 +45,10 @@ detect_compose_cmd() {
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
print_error "Docker Compose 未安装!请先安装 Docker Compose"
|
||||
print_error "Docker Compose not found. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
print_info "使用 Docker Compose 命令: $COMPOSE_CMD"
|
||||
print_info "Using Docker Compose: $COMPOSE_CMD"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -56,12 +56,12 @@ detect_compose_cmd() {
|
||||
# ------------------------------------------------------------------------
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker 未安装!请先安装 Docker: https://docs.docker.com/get-docker/"
|
||||
print_error "Docker not found. Please install Docker: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_compose_cmd
|
||||
print_success "Docker 和 Docker Compose 已安装"
|
||||
print_success "Docker and Docker Compose are installed"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -69,11 +69,11 @@ check_docker() {
|
||||
# ------------------------------------------------------------------------
|
||||
check_env() {
|
||||
if [ ! -f ".env" ]; then
|
||||
print_warning ".env 不存在,从模板复制..."
|
||||
print_warning ".env not found, copying from template..."
|
||||
cp .env.example .env
|
||||
print_info "已创建 .env 文件"
|
||||
print_info ".env file created"
|
||||
fi
|
||||
print_success "环境变量文件存在"
|
||||
print_success "Environment file exists"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -83,15 +83,15 @@ is_env_configured() {
|
||||
local var_name="$1"
|
||||
local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-)
|
||||
|
||||
# 去除引号
|
||||
# Strip quotes
|
||||
value=$(echo "$value" | tr -d '"'"'")
|
||||
|
||||
# 检查是否为空或占位符
|
||||
# Check empty
|
||||
if [ -z "$value" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查是否是示例值
|
||||
# Check placeholder values
|
||||
case "$value" in
|
||||
*your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*)
|
||||
return 1
|
||||
@@ -102,22 +102,23 @@ is_env_configured() {
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Helper: Generate and set env var in .env file
|
||||
# Helper: Set env var in .env file
|
||||
# ------------------------------------------------------------------------
|
||||
set_env_var() {
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
|
||||
# 如果变量已存在(即使是占位符),替换它
|
||||
if grep -q "^${var_name}=" .env 2>/dev/null; then
|
||||
# macOS 和 Linux 兼容的 sed
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||
else
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||
fi
|
||||
else
|
||||
# 变量不存在,追加
|
||||
# Ensure .env ends with a newline before appending
|
||||
if [ -s ".env" ] && [ "$(tail -c1 .env | wc -l)" -eq 0 ]; then
|
||||
echo "" >> .env
|
||||
fi
|
||||
echo "${var_name}=${var_value}" >> .env
|
||||
fi
|
||||
}
|
||||
@@ -126,51 +127,46 @@ set_env_var() {
|
||||
# Validation: Encryption Keys in .env
|
||||
# ------------------------------------------------------------------------
|
||||
check_encryption() {
|
||||
print_info "检查加密密钥配置..."
|
||||
print_info "Checking encryption keys..."
|
||||
|
||||
local generated=false
|
||||
|
||||
# 检查并生成 JWT_SECRET
|
||||
if ! is_env_configured "JWT_SECRET"; then
|
||||
print_warning "JWT_SECRET 未配置,正在生成..."
|
||||
print_warning "JWT_SECRET not set, generating..."
|
||||
local jwt_secret=$(openssl rand -base64 32)
|
||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
||||
print_success "JWT_SECRET 已生成"
|
||||
print_success "JWT_SECRET generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
# 检查并生成 DATA_ENCRYPTION_KEY
|
||||
if ! is_env_configured "DATA_ENCRYPTION_KEY"; then
|
||||
print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..."
|
||||
print_warning "DATA_ENCRYPTION_KEY not set, generating..."
|
||||
local data_key=$(openssl rand -base64 32)
|
||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
||||
print_success "DATA_ENCRYPTION_KEY 已生成"
|
||||
print_success "DATA_ENCRYPTION_KEY generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
# 检查并生成 RSA_PRIVATE_KEY
|
||||
if ! is_env_configured "RSA_PRIVATE_KEY"; then
|
||||
print_warning "RSA_PRIVATE_KEY 未配置,正在生成..."
|
||||
# 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n)
|
||||
print_warning "RSA_PRIVATE_KEY not set, generating..."
|
||||
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
||||
print_success "RSA_PRIVATE_KEY 已生成"
|
||||
print_success "RSA_PRIVATE_KEY generated"
|
||||
generated=true
|
||||
fi
|
||||
|
||||
if [ "$generated" = true ]; then
|
||||
echo ""
|
||||
print_success "所有缺失的密钥已自动生成并保存到 .env"
|
||||
print_warning "请妥善保管 .env 文件,不要提交到版本控制系统"
|
||||
print_success "Missing keys generated and saved to .env"
|
||||
print_warning "Keep .env safe — do not commit it to version control"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
print_success "加密密钥检查完成"
|
||||
print_success "Encryption keys OK"
|
||||
print_info " • JWT_SECRET: OK"
|
||||
print_info " • DATA_ENCRYPTION_KEY: OK"
|
||||
print_info " • RSA_PRIVATE_KEY: OK"
|
||||
|
||||
# 修复 .env 文件权限
|
||||
chmod 600 .env 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -197,13 +193,12 @@ read_env_vars() {
|
||||
# Validation: Database Directory (data/)
|
||||
# ------------------------------------------------------------------------
|
||||
check_database() {
|
||||
# Ensure data directory exists
|
||||
if [ ! -d "data" ]; then
|
||||
print_warning "数据目录不存在,创建 data/ 目录..."
|
||||
print_warning "Data directory missing, creating data/..."
|
||||
install -m 700 -d data
|
||||
print_success "已创建 data/ 目录"
|
||||
print_success "data/ directory created"
|
||||
else
|
||||
print_success "数据目录存在"
|
||||
print_success "Data directory exists"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -211,47 +206,58 @@ check_database() {
|
||||
# Service Management: Start
|
||||
# ------------------------------------------------------------------------
|
||||
start() {
|
||||
print_info "正在启动 NOFX AI Trading System..."
|
||||
echo ""
|
||||
echo -e "${CYAN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ 🚀 NOFX AI Trading Bot — Startup ║${NC}"
|
||||
echo -e "${CYAN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
read_env_vars
|
||||
|
||||
if [ ! -d "data" ]; then
|
||||
print_info "创建数据目录..."
|
||||
install -m 700 -d data
|
||||
fi
|
||||
|
||||
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
print_info "Starting services..."
|
||||
|
||||
if [ "$1" == "--build" ]; then
|
||||
print_info "重新构建镜像..."
|
||||
$COMPOSE_CMD up -d --build
|
||||
else
|
||||
print_info "启动容器..."
|
||||
$COMPOSE_CMD up -d
|
||||
fi
|
||||
|
||||
print_success "服务已启动!"
|
||||
print_info "Web 界面: http://localhost:${NOFX_FRONTEND_PORT}"
|
||||
print_info "API 端点: http://localhost:${NOFX_BACKEND_PORT}"
|
||||
print_info ""
|
||||
print_info "查看日志: ./start.sh logs"
|
||||
print_info "停止服务: ./start.sh stop"
|
||||
echo ""
|
||||
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}"
|
||||
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo " 1. Open the web dashboard to register and configure"
|
||||
echo " 2. Add an AI model and exchange in Settings"
|
||||
echo " 3. (Optional) Add a Telegram bot token in Settings → Telegram"
|
||||
echo ""
|
||||
echo -e " Web dashboard: ${BLUE}http://localhost:${NOFX_FRONTEND_PORT}${NC}"
|
||||
echo -e " View logs: ${YELLOW}./start.sh logs${NC}"
|
||||
echo -e " Stop: ${YELLOW}./start.sh stop${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Service Management: Stop
|
||||
# ------------------------------------------------------------------------
|
||||
stop() {
|
||||
print_info "正在停止服务..."
|
||||
print_info "Stopping services..."
|
||||
$COMPOSE_CMD stop
|
||||
print_success "服务已停止"
|
||||
print_success "Services stopped"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Service Management: Restart
|
||||
# ------------------------------------------------------------------------
|
||||
restart() {
|
||||
print_info "正在重启服务..."
|
||||
print_info "Restarting services..."
|
||||
$COMPOSE_CMD restart
|
||||
print_success "服务已重启"
|
||||
print_success "Services restarted"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -271,25 +277,25 @@ logs() {
|
||||
status() {
|
||||
read_env_vars
|
||||
|
||||
print_info "服务状态:"
|
||||
print_info "Service status:"
|
||||
$COMPOSE_CMD ps
|
||||
echo ""
|
||||
print_info "健康检查:"
|
||||
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应"
|
||||
print_info "Health check:"
|
||||
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Maintenance: Clean (Destructive)
|
||||
# ------------------------------------------------------------------------
|
||||
clean() {
|
||||
print_warning "这将删除所有容器和数据!"
|
||||
read -p "确认删除?(yes/no): " confirm
|
||||
print_warning "This will delete all containers and data!"
|
||||
read -p "Confirm? (yes/no): " confirm
|
||||
if [ "$confirm" == "yes" ]; then
|
||||
print_info "正在清理..."
|
||||
print_info "Cleaning up..."
|
||||
$COMPOSE_CMD down -v
|
||||
print_success "清理完成"
|
||||
print_success "Cleanup complete"
|
||||
else
|
||||
print_info "已取消"
|
||||
print_info "Cancelled"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -297,77 +303,74 @@ clean() {
|
||||
# Maintenance: Update
|
||||
# ------------------------------------------------------------------------
|
||||
update() {
|
||||
print_info "正在更新..."
|
||||
print_info "Updating..."
|
||||
git pull
|
||||
$COMPOSE_CMD up -d --build
|
||||
print_success "更新完成"
|
||||
print_success "Update complete"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Command: Regenerate all keys (force)
|
||||
# ------------------------------------------------------------------------
|
||||
regenerate_keys() {
|
||||
print_warning "这将重新生成所有加密密钥!"
|
||||
print_warning "如果已有加密数据,重新生成后将无法解密!"
|
||||
print_warning "This will regenerate ALL encryption keys!"
|
||||
print_warning "Any existing encrypted data will become unreadable!"
|
||||
echo ""
|
||||
read -p "确认重新生成?(yes/no): " confirm
|
||||
read -p "Confirm? (yes/no): " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
print_info "已取消"
|
||||
print_info "Cancelled"
|
||||
return
|
||||
fi
|
||||
|
||||
check_env
|
||||
|
||||
print_info "正在生成新的密钥..."
|
||||
print_info "Generating new keys..."
|
||||
|
||||
# 生成 JWT_SECRET
|
||||
local jwt_secret=$(openssl rand -base64 32)
|
||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
||||
print_success "JWT_SECRET 已生成"
|
||||
print_success "JWT_SECRET generated"
|
||||
|
||||
# 生成 DATA_ENCRYPTION_KEY
|
||||
local data_key=$(openssl rand -base64 32)
|
||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
||||
print_success "DATA_ENCRYPTION_KEY 已生成"
|
||||
print_success "DATA_ENCRYPTION_KEY generated"
|
||||
|
||||
# 生成 RSA_PRIVATE_KEY
|
||||
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
||||
print_success "RSA_PRIVATE_KEY 已生成"
|
||||
print_success "RSA_PRIVATE_KEY generated"
|
||||
|
||||
chmod 600 .env 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
print_success "所有密钥已重新生成并保存到 .env"
|
||||
print_warning "请妥善保管 .env 文件"
|
||||
print_success "All keys regenerated and saved to .env"
|
||||
print_warning "Keep .env safe"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# Help: Usage Information
|
||||
# ------------------------------------------------------------------------
|
||||
show_help() {
|
||||
echo "NOFX AI Trading System - Docker 管理脚本"
|
||||
echo "NOFX AI Trading System - Docker Management Script"
|
||||
echo ""
|
||||
echo "用法: ./start.sh [command] [options]"
|
||||
echo "Usage: ./start.sh [command] [options]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start [--build] 启动服务(可选:重新构建)"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务"
|
||||
echo " logs [service] 查看日志(可选:指定服务名 backend/frontend)"
|
||||
echo " status 查看服务状态"
|
||||
echo " clean 清理所有容器和数据"
|
||||
echo " update 更新代码并重启"
|
||||
echo " regenerate-keys 重新生成所有加密密钥(慎用)"
|
||||
echo " help 显示此帮助信息"
|
||||
echo "Commands:"
|
||||
echo " start [--build] Start services (optional: rebuild images)"
|
||||
echo " stop Stop services"
|
||||
echo " restart Restart services"
|
||||
echo " logs [service] View logs (optional: backend / frontend)"
|
||||
echo " status Show service status"
|
||||
echo " clean Remove all containers and data"
|
||||
echo " update Pull latest code and rebuild"
|
||||
echo " regenerate-keys Regenerate all encryption keys (destructive)"
|
||||
echo " help Show this help"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./start.sh start --build # 构建并启动"
|
||||
echo " ./start.sh logs backend # 查看后端日志"
|
||||
echo " ./start.sh status # 查看状态"
|
||||
echo "Examples:"
|
||||
echo " ./start.sh start --build # Build and start"
|
||||
echo " ./start.sh logs backend # View backend logs"
|
||||
echo " ./start.sh status # Check status"
|
||||
echo ""
|
||||
echo "首次使用:"
|
||||
echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成"
|
||||
echo "First time:"
|
||||
echo " Just run ./start.sh — missing keys are generated automatically"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
@@ -408,7 +411,7 @@ main() {
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_error "未知命令: $1"
|
||||
print_error "Unknown command: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
|
||||
@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
|
||||
@@ -53,7 +53,9 @@ func (s *EquityStore) Save(snapshot *EquitySnapshot) error {
|
||||
snapshot.Timestamp = snapshot.Timestamp.UTC()
|
||||
}
|
||||
|
||||
if err := s.db.Create(snapshot).Error; err != nil {
|
||||
// Omit ID to let PostgreSQL sequence auto-generate it
|
||||
// Without this, GORM inserts ID=0 which causes duplicate key errors
|
||||
if err := s.db.Omit("ID").Create(snapshot).Error; err != nil {
|
||||
return fmt.Errorf("failed to save equity snapshot: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -29,6 +29,7 @@ type Exchange struct {
|
||||
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"`
|
||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||
@@ -173,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
return "Aster DEX", "dex"
|
||||
case "lighter":
|
||||
return "LIGHTER DEX", "dex"
|
||||
case "indodax":
|
||||
return "Indodax", "cex"
|
||||
default:
|
||||
return exchangeType + " Exchange", "cex"
|
||||
}
|
||||
@@ -181,7 +184,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
// Create creates a new exchange account with UUID
|
||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||
apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
asterUser, asterSigner, asterPrivateKey,
|
||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
||||
|
||||
id := uuid.New().String()
|
||||
@@ -207,6 +211,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
Passphrase: crypto.EncryptedString(passphrase),
|
||||
Testnet: testnet,
|
||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||
AsterUser: asterUser,
|
||||
AsterSigner: asterSigner,
|
||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||
@@ -224,7 +229,8 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
|
||||
// Update updates exchange configuration by UUID
|
||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||
|
||||
@@ -232,6 +238,7 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
|
||||
"enabled": enabled,
|
||||
"testnet": testnet,
|
||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||
"aster_user": asterUser,
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
@@ -307,7 +314,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
|
||||
// Check if this is an old-style ID (exchange type as ID)
|
||||
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
||||
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
hyperliquidWalletAddr, true, // Default to Unified Account mode
|
||||
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ type GridConfigModel struct {
|
||||
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
||||
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
||||
IsActive bool `json:"is_active" gorm:"default:false"`
|
||||
|
||||
// Direction adjustment settings
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust" gorm:"default:false"`
|
||||
DirectionBiasRatio float64 `json:"direction_bias_ratio" gorm:"default:0.7"`
|
||||
}
|
||||
|
||||
func (GridConfigModel) TableName() string {
|
||||
@@ -108,6 +112,11 @@ type GridInstanceModel struct {
|
||||
// Position adjustment due to breakout
|
||||
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
|
||||
|
||||
// Grid direction adjustment state
|
||||
CurrentDirection string `json:"current_direction" gorm:"default:neutral"`
|
||||
DirectionChangedAt time.Time `json:"direction_changed_at"`
|
||||
DirectionChangeCount int `json:"direction_change_count" gorm:"default:0"`
|
||||
|
||||
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
|
||||
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
||||
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
||||
|
||||
@@ -3,12 +3,63 @@ package store
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision.
|
||||
// For small prices (like meme coins), it preserves more decimal places.
|
||||
// It detects the number of decimal places needed from the reference price(s).
|
||||
func adaptivePriceRound(price float64, referencePrices ...float64) float64 {
|
||||
if price == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Find the minimum magnitude among all prices (including the price itself)
|
||||
minMagnitude := math.Abs(price)
|
||||
for _, ref := range referencePrices {
|
||||
if ref > 0 && ref < minMagnitude {
|
||||
minMagnitude = ref
|
||||
}
|
||||
}
|
||||
|
||||
// Determine decimal places needed based on price magnitude
|
||||
// For price 0.000000541, we need ~15 decimal places
|
||||
// For price 0.0001, we need ~8 decimal places
|
||||
// For price 1.0, we need ~4 decimal places
|
||||
var multiplier float64
|
||||
switch {
|
||||
case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB)
|
||||
multiplier = 1e15 // 15 decimal places
|
||||
case minMagnitude < 0.0001: // Very small (PEPE, FLOKI)
|
||||
multiplier = 1e12 // 12 decimal places
|
||||
case minMagnitude < 0.01: // Small
|
||||
multiplier = 1e10 // 10 decimal places
|
||||
case minMagnitude < 1: // Medium
|
||||
multiplier = 1e8 // 8 decimal places
|
||||
default: // Large
|
||||
multiplier = 1e6 // 6 decimal places
|
||||
}
|
||||
|
||||
return math.Round(price*multiplier) / multiplier
|
||||
}
|
||||
|
||||
// getPriceDecimalPlaces returns the number of decimal places in a price string
|
||||
func getPriceDecimalPlaces(price float64) int {
|
||||
if price == 0 {
|
||||
return 0
|
||||
}
|
||||
s := strconv.FormatFloat(price, 'f', -1, 64)
|
||||
idx := strings.Index(s, ".")
|
||||
if idx == -1 {
|
||||
return 0
|
||||
}
|
||||
return len(s) - idx - 1
|
||||
}
|
||||
|
||||
// TraderStats trading statistics metrics
|
||||
type TraderStats struct {
|
||||
TotalTrades int `json:"total_trades"`
|
||||
@@ -156,7 +207,8 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
|
||||
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
|
||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
|
||||
newFee := pos.Fee + addFee
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
@@ -187,7 +239,8 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
var newExitPrice float64
|
||||
if newClosedQty > 0 {
|
||||
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
@@ -147,7 +147,8 @@ func (pb *PositionBuilder) handleClose(
|
||||
var finalExitPrice float64
|
||||
if totalClosed > 0 {
|
||||
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
||||
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
||||
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
|
||||
} else {
|
||||
finalExitPrice = price
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type Store struct {
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
telegramConfig TelegramConfigStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -160,6 +161,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -293,6 +297,16 @@ 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 {
|
||||
|
||||
@@ -81,6 +81,10 @@ type GridStrategyConfig struct {
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||||
// Use maker-only orders for lower fees
|
||||
UseMakerOnly bool `json:"use_maker_only"`
|
||||
// Enable automatic grid direction adjustment based on box breakouts
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||||
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||||
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
|
||||
}
|
||||
|
||||
// PromptSectionsConfig editable sections of System Prompt
|
||||
@@ -115,6 +119,12 @@ 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
|
||||
}
|
||||
|
||||
|
||||
164
store/telegram_config.go
Normal file
164
store/telegram_config.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TelegramConfig stores the Telegram bot binding (single row, always ID=1)
|
||||
type TelegramConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BotToken string `gorm:"column:bot_token"`
|
||||
ChatID int64 `gorm:"column:chat_id"`
|
||||
Username string `gorm:"column:username"` // @username for display
|
||||
BoundAt time.Time `gorm:"column:bound_at"`
|
||||
ModelID string `gorm:"column:model_id;default:''"` // AI model used for Telegram replies
|
||||
Language string `gorm:"column:language;default:''"` // "zh" or "en"; empty = not chosen yet
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// String returns a safe string representation of TelegramConfig with the token masked.
|
||||
func (tc TelegramConfig) String() string {
|
||||
token := "***"
|
||||
if tc.BotToken == "" {
|
||||
token = "<not set>"
|
||||
}
|
||||
return fmt.Sprintf("TelegramConfig{ID:%d, ChatID:%d, Username:%q, BotToken:%s, BoundAt:%v}",
|
||||
tc.ID, tc.ChatID, tc.Username, token, tc.BoundAt)
|
||||
}
|
||||
|
||||
// TelegramConfigStore defines the interface for Telegram bot binding operations
|
||||
type TelegramConfigStore interface {
|
||||
Get() (*TelegramConfig, error) // Get current config (may not exist)
|
||||
SaveToken(botToken string) error // Save bot token only (Web UI sets this)
|
||||
Save(botToken, modelID string) error // Save bot token + selected AI model
|
||||
BindUser(chatID int64, username string) error // Called on first /start
|
||||
IsBound() (bool, error) // Check if any user is bound
|
||||
GetBoundChatID() (int64, error) // Get bound chat ID (0 if not bound)
|
||||
Unbind() error // Remove binding
|
||||
SetLanguage(lang string) error // Set UI language ("en" or "zh")
|
||||
GetLanguage() string // Get UI language; returns "en" if not set
|
||||
}
|
||||
|
||||
type telegramConfigStore struct {
|
||||
db *gorm.DB
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTelegramConfigStore creates a new TelegramConfigStore
|
||||
func NewTelegramConfigStore(db *gorm.DB) TelegramConfigStore {
|
||||
return &telegramConfigStore{db: db}
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) initTables() error {
|
||||
return s.db.AutoMigrate(&TelegramConfig{})
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Get() (*TelegramConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SaveToken(botToken string) error {
|
||||
return s.Save(botToken, "")
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Save(botToken, modelID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.BotToken = botToken
|
||||
cfg.ModelID = modelID
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) BindUser(chatID int64, username string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.ChatID = chatID
|
||||
cfg.Username = username
|
||||
cfg.BoundAt = time.Now()
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) IsBound() (bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return cfg.ChatID != 0, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetBoundChatID() (int64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return cfg.ChatID, nil
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) Unbind() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.db.Model(&TelegramConfig{}).Where("id = 1").Updates(map[string]interface{}{
|
||||
"chat_id": 0,
|
||||
"username": "",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) SetLanguage(lang string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var cfg TelegramConfig
|
||||
result := s.db.First(&cfg, 1)
|
||||
if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return result.Error
|
||||
}
|
||||
cfg.ID = 1
|
||||
cfg.Language = lang
|
||||
return s.db.Save(&cfg).Error
|
||||
}
|
||||
|
||||
func (s *telegramConfigStore) GetLanguage() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg TelegramConfig
|
||||
if err := s.db.First(&cfg, 1).Error; err != nil {
|
||||
return "en" // default: English
|
||||
}
|
||||
if cfg.Language == "" {
|
||||
return "en"
|
||||
}
|
||||
return cfg.Language
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -18,24 +16,12 @@ type User struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||
OTPSecret string `gorm:"column:otp_secret" json:"-"`
|
||||
OTPVerified bool `gorm:"column:otp_verified;default:false" json:"otp_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.EncodeToString(secret), nil
|
||||
}
|
||||
|
||||
// NewUserStore creates a new UserStore
|
||||
func NewUserStore(db *gorm.DB) *UserStore {
|
||||
return &UserStore{db: db}
|
||||
@@ -54,9 +40,6 @@ func (s *UserStore) initTables() error {
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||
// OTP columns (added later)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_secret TEXT DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_verified BOOLEAN DEFAULT FALSE`)
|
||||
|
||||
// Ensure unique index exists on email (don't care about the name)
|
||||
var indexExists int64
|
||||
@@ -114,9 +97,11 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
|
||||
return userIDs, err
|
||||
}
|
||||
|
||||
// UpdateOTPVerified updates OTP verification status
|
||||
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
|
||||
// GetAll returns all users ordered by creation time.
|
||||
func (s *UserStore) GetAll() ([]User, error) {
|
||||
var users []User
|
||||
err := s.db.Model(&User{}).Order("created_at").Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
// UpdatePassword updates password
|
||||
@@ -138,7 +123,5 @@ func (s *UserStore) EnsureAdmin() error {
|
||||
ID: "admin",
|
||||
Email: "admin@localhost",
|
||||
PasswordHash: "",
|
||||
OTPSecret: "",
|
||||
OTPVerified: true,
|
||||
})
|
||||
}
|
||||
|
||||
285
telegram/agent/agent.go
Normal file
285
telegram/agent/agent.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/auth"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/telegram/session"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxIterations = 10
|
||||
|
||||
// apiRequestTool is the single tool exposed to the LLM.
|
||||
// Native function calling means the LLM returns EITHER ToolCalls OR Content — never both.
|
||||
// This makes narration structurally impossible: text cannot appear alongside a tool call.
|
||||
var apiRequestTool = mcp.Tool{
|
||||
Type: "function",
|
||||
Function: mcp.FunctionDef{
|
||||
Name: "api_request",
|
||||
Description: "Call the NOFX trading system REST API",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"method": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"GET", "POST", "PUT", "DELETE"},
|
||||
"description": "HTTP method",
|
||||
},
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "API path; include query params in path: /api/positions?trader_id=xxx",
|
||||
},
|
||||
"body": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Request body; use {} for GET requests",
|
||||
},
|
||||
},
|
||||
"required": []string{"method", "path", "body"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Agent is a stateful AI agent for one Telegram chat.
|
||||
// It exposes a single "api_request" tool and runs a loop until the LLM
|
||||
// returns a plain-text reply (no tool calls).
|
||||
type Agent struct {
|
||||
apiTool *apiCallTool
|
||||
getLLM func() mcp.AIClient
|
||||
memory *session.Memory
|
||||
systemPrompt string
|
||||
userID string
|
||||
}
|
||||
|
||||
// New creates an Agent for one chat session.
|
||||
func New(apiPort int, botToken, userID string, getLLM func() mcp.AIClient, systemPrompt string) *Agent {
|
||||
return &Agent{
|
||||
apiTool: newAPICallTool(apiPort, botToken),
|
||||
getLLM: getLLM,
|
||||
memory: session.NewMemory(getLLM()),
|
||||
systemPrompt: systemPrompt,
|
||||
userID: userID,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateBotToken creates a long-lived JWT for the bot's internal API calls.
|
||||
// userID must match the actual registered user's ID so bot-made changes
|
||||
// are visible in the frontend (shared user namespace).
|
||||
func GenerateBotToken(userID string) (string, error) {
|
||||
return auth.GenerateJWT(userID, "bot@internal")
|
||||
}
|
||||
|
||||
// buildAccountContext fetches the live account state (models, exchanges, strategies, traders,
|
||||
// and per-trader account summary + statistics) and returns it as a formatted string for
|
||||
// injection into the LLM context at the start of each conversation.
|
||||
func (a *Agent) buildAccountContext() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("[Current Account State — User: %s]\n\n", a.userID))
|
||||
|
||||
// ── AI Models ─────────────────────────────────────────────────────────────
|
||||
modelsRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/models"})
|
||||
sb.WriteString("## AI Models\n")
|
||||
sb.WriteString("⚠️ When creating a trader, use the EXACT \"id\" value below for \"ai_model_id\".\n")
|
||||
sb.WriteString(" DO NOT use the \"provider\" field — it is NOT a valid ai_model_id.\n\n")
|
||||
|
||||
var models []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelsRaw), &models); err == nil && len(models) > 0 {
|
||||
for _, m := range models {
|
||||
status := "disabled"
|
||||
if m.Enabled {
|
||||
status = "ENABLED"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • ai_model_id=\"%s\" provider=%s name=%s [%s]\n", m.ID, m.Provider, m.Name, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(modelsRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Exchanges ─────────────────────────────────────────────────────────────
|
||||
exchangesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/exchanges"})
|
||||
sb.WriteString("## Exchanges\n")
|
||||
sb.WriteString("⚠️ Use the EXACT \"id\" value below for \"exchange_id\" when creating a trader.\n\n")
|
||||
|
||||
var exchanges []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExchangeType string `json:"exchange_type"`
|
||||
AccountName string `json:"account_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangesRaw), &exchanges); err == nil && len(exchanges) > 0 {
|
||||
for _, e := range exchanges {
|
||||
status := "disabled"
|
||||
if e.Enabled {
|
||||
status = "ENABLED"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • exchange_id=\"%s\" type=%s account=%s [%s]\n", e.ID, e.ExchangeType, e.AccountName, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(exchangesRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Strategies ────────────────────────────────────────────────────────────
|
||||
strategiesRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/strategies"})
|
||||
sb.WriteString("## Strategies\n")
|
||||
|
||||
var strategies []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strategiesRaw), &strategies); err == nil && len(strategies) > 0 {
|
||||
for _, s := range strategies {
|
||||
sb.WriteString(fmt.Sprintf(" • strategy_id=\"%s\" name=%s\n", s.ID, s.Name))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(strategiesRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Traders ───────────────────────────────────────────────────────────────
|
||||
tradersRaw := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/my-traders"})
|
||||
sb.WriteString("## Traders\n")
|
||||
|
||||
var traders []struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Name string `json:"trader_name"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tradersRaw), &traders); err == nil && len(traders) > 0 {
|
||||
for _, t := range traders {
|
||||
status := "stopped"
|
||||
if t.IsRunning {
|
||||
status = "RUNNING"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • trader_id=\"%s\" name=%s [%s]\n", t.TraderID, t.Name, status))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(tradersRaw)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// ── Per-trader live data (running traders only) ────────────────────────────
|
||||
for _, t := range traders {
|
||||
if !t.IsRunning {
|
||||
continue
|
||||
}
|
||||
acct := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/account?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Account [%s]:\n%s\n\n", t.Name, acct))
|
||||
stats := a.apiTool.execute(&apiRequest{Method: "GET", Path: "/api/statistics?trader_id=" + t.TraderID})
|
||||
sb.WriteString(fmt.Sprintf("Statistics [%s]:\n%s\n\n", t.Name, stats))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Run processes one user message through the native function-calling agent loop.
|
||||
//
|
||||
// Architecture:
|
||||
// - LLM receives the api_request tool definition alongside conversation history.
|
||||
// - LLM response is EITHER ToolCalls (execute API) OR Content (final reply) — never both.
|
||||
// This is enforced by the protocol: narration is structurally impossible.
|
||||
// - Loop continues until the LLM returns a plain-text reply (no tool calls).
|
||||
//
|
||||
// On the first message of a conversation the live account state is fetched and injected.
|
||||
// onChunk is optional; when set it is called once with the complete final reply text.
|
||||
func (a *Agent) Run(userMessage string, onChunk func(string)) string {
|
||||
llm := a.getLLM()
|
||||
if llm == nil {
|
||||
return "AI assistant unavailable. Please configure an AI model in the Web UI."
|
||||
}
|
||||
|
||||
// Build initial user message: prepend account state on first turn, history on subsequent turns.
|
||||
histCtx := a.memory.BuildContext()
|
||||
var firstUserContent string
|
||||
if histCtx == "" {
|
||||
accountCtx := a.buildAccountContext()
|
||||
firstUserContent = accountCtx + "\n[User Message]\n" + userMessage
|
||||
} else {
|
||||
firstUserContent = histCtx + "\n---\nUser: " + userMessage
|
||||
}
|
||||
|
||||
turnMsgs := []mcp.Message{mcp.NewUserMessage(firstUserContent)}
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
req, err := mcp.NewRequestBuilder().
|
||||
WithSystemPrompt(a.systemPrompt).
|
||||
AddConversationHistory(turnMsgs).
|
||||
AddTool(apiRequestTool).
|
||||
WithToolChoice("auto").
|
||||
Build()
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: failed to build request: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
resp, err := llm.CallWithRequestFull(req)
|
||||
if err != nil {
|
||||
logger.Errorf("Agent: LLM call failed (iteration %d): %v", i+1, err)
|
||||
return "AI assistant temporarily unavailable. Please try again."
|
||||
}
|
||||
|
||||
// No tool calls → LLM returned a final text reply.
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
reply := strings.TrimSpace(resp.Content)
|
||||
if onChunk != nil {
|
||||
onChunk(reply)
|
||||
}
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// Tool call iteration — show thinking indicator.
|
||||
if onChunk != nil {
|
||||
onChunk("⏳")
|
||||
}
|
||||
|
||||
// Append assistant message carrying the tool calls (no content field).
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: resp.ToolCalls,
|
||||
})
|
||||
|
||||
// Execute each tool call and append the results as tool messages.
|
||||
for _, tc := range resp.ToolCalls {
|
||||
var apiReq apiRequest
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &apiReq); err != nil {
|
||||
logger.Errorf("Agent: invalid tool args for call %s: %v", tc.ID, err)
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Content: fmt.Sprintf(`{"error":"invalid arguments: %s"}`, err.Error()),
|
||||
})
|
||||
continue
|
||||
}
|
||||
logger.Infof("Agent: iter=%d tool=%s %s %s", i+1, tc.ID, apiReq.Method, apiReq.Path)
|
||||
result := a.apiTool.execute(&apiReq)
|
||||
turnMsgs = append(turnMsgs, mcp.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Content: result,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: max iterations reached.
|
||||
logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage)
|
||||
reply := "操作已完成,请检查您的账户查看最新状态。"
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
}
|
||||
|
||||
// ResetMemory clears conversation history (called on /start).
|
||||
func (a *Agent) ResetMemory() {
|
||||
a.memory.ResetFull()
|
||||
}
|
||||
439
telegram/agent/agent_test.go
Normal file
439
telegram/agent/agent_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
// mockLLM implements mcp.AIClient using pre-programmed LLMResponse objects.
|
||||
// Native function calling: CallWithRequestFull is the primary method;
|
||||
// CallWithRequest and CallWithRequestStream are stubs kept for interface compliance.
|
||||
type mockLLM struct {
|
||||
responses []*mcp.LLMResponse
|
||||
calls int
|
||||
lastMsgs []mcp.Message
|
||||
}
|
||||
|
||||
func (m *mockLLM) SetAPIKey(_, _, _ string) {}
|
||||
func (m *mockLLM) SetTimeout(_ time.Duration) {}
|
||||
|
||||
func (m *mockLLM) CallWithMessages(_, _ string) (string, error) { return "", nil }
|
||||
|
||||
func (m *mockLLM) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
r, err := m.next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
func (m *mockLLM) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
|
||||
r, err := m.next()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if onChunk != nil {
|
||||
onChunk(r.Content)
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
func (m *mockLLM) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
||||
m.lastMsgs = req.Messages
|
||||
return m.next()
|
||||
}
|
||||
|
||||
func (m *mockLLM) next() (*mcp.LLMResponse, error) {
|
||||
if m.calls < len(m.responses) {
|
||||
r := m.responses[m.calls]
|
||||
m.calls++
|
||||
return r, nil
|
||||
}
|
||||
return &mcp.LLMResponse{Content: "OK"}, nil
|
||||
}
|
||||
|
||||
// toolCall builds a mock LLM response that contains a single tool invocation.
|
||||
func toolCall(id, method, path string, body string) *mcp.LLMResponse {
|
||||
if body == "" {
|
||||
body = "{}"
|
||||
}
|
||||
return &mcp.LLMResponse{
|
||||
ToolCalls: []mcp.ToolCall{{
|
||||
ID: id,
|
||||
Type: "function",
|
||||
Function: mcp.ToolCallFunction{
|
||||
Name: "api_request",
|
||||
Arguments: fmt.Sprintf(`{"method":%q,"path":%q,"body":%s}`, method, path, body),
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// textReply builds a mock LLM response with a plain-text final answer.
|
||||
func textReply(content string) *mcp.LLMResponse {
|
||||
return &mcp.LLMResponse{Content: content}
|
||||
}
|
||||
|
||||
func mockGetLLM(llm *mockLLM) func() mcp.AIClient {
|
||||
return func() mcp.AIClient { return llm }
|
||||
}
|
||||
|
||||
const testPrompt = "You are a test assistant."
|
||||
|
||||
// mockAPIServer creates a test HTTP server with configurable route handlers.
|
||||
func mockAPIServer(handlers map[string]string) (*httptest.Server, int) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
if body, ok := handlers[key]; ok {
|
||||
w.Write([]byte(body)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
// Also try path-only match (for GET)
|
||||
if body, ok := handlers[r.URL.Path]; ok {
|
||||
w.Write([]byte(body)) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"error":"not found"}`)) //nolint:errcheck
|
||||
}))
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
return srv, port
|
||||
}
|
||||
|
||||
// ── Basic agent behaviour ──────────────────────────────────────────────────
|
||||
|
||||
// TestAgentDirectReply: LLM replies with text (no tool calls) — one LLM call.
|
||||
func TestAgentDirectReply(t *testing.T) {
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{textReply("Hello! How can I help you?")}}
|
||||
a := New(8080, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("hello", nil)
|
||||
|
||||
if reply != "Hello! How can I help you?" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 1 {
|
||||
t.Fatalf("expected 1 LLM call, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPICall: LLM makes one tool call, gets result, gives final reply — two LLM calls.
|
||||
func TestAgentAPICall(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/my-traders": `[{"trader_id":"t1","trader_name":"BTC Trader","is_running":false}]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/my-traders", "{}"),
|
||||
textReply("You have one trader: BTC Trader."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("list my traders", nil)
|
||||
|
||||
if reply != "You have one trader: BTC Trader." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
if llm.calls != 2 {
|
||||
t.Fatalf("expected 2 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentMultiStep: LLM chains two tool calls before final reply — three LLM calls.
|
||||
func TestAgentMultiStep(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"total_equity":1000}`,
|
||||
"/api/positions": `[]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
toolCall("c2", "GET", "/api/positions", "{}"),
|
||||
textReply("Account looks healthy and no open positions."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
reply := a.Run("show me account status", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls (2 tool + 1 final), got %d", llm.calls)
|
||||
}
|
||||
if reply != "Account looks healthy and no open positions." {
|
||||
t.Fatalf("unexpected final reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentAPIResultInContext: tool result must appear as a tool message in the next LLM call.
|
||||
func TestAgentAPIResultInContext(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"balance":1234.56}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
textReply("Balance is 1234.56 USDT."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("show balance", nil)
|
||||
|
||||
// The last request must contain a tool-result message with the balance data.
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if msg.Role == "tool" && strings.Contains(msg.Content, "balance") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool result message not found in subsequent LLM context; messages: %+v", llm.lastMsgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Narration-free architecture tests ─────────────────────────────────────
|
||||
|
||||
// TestNarrationStructurallyImpossible: when ToolCalls are present in the response,
|
||||
// any Content field is ignored and never surfaced to the user.
|
||||
// In real LLM APIs, Content is always empty alongside ToolCalls, but we verify
|
||||
// our agent handles a malformed response defensively.
|
||||
func TestNarrationStructurallyImpossible(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/strategies": `[{"id":"s1","name":"BTC Trend"}]`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
// Simulate a (malformed) response that has both Content and ToolCalls.
|
||||
malformed := &mcp.LLMResponse{
|
||||
Content: "现在我将为您查询策略。", // narration — must NOT reach user
|
||||
ToolCalls: []mcp.ToolCall{{
|
||||
ID: "c1",
|
||||
Type: "function",
|
||||
Function: mcp.ToolCallFunction{
|
||||
Name: "api_request",
|
||||
Arguments: `{"method":"GET","path":"/api/strategies","body":{}}`,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
malformed,
|
||||
textReply("你有1个策略:BTC Trend。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("查询我的策略", nil)
|
||||
|
||||
if strings.Contains(reply, "现在我将") {
|
||||
t.Fatalf("narration leaked into final reply: %q", reply)
|
||||
}
|
||||
if reply != "你有1个策略:BTC Trend。" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnChunkCalledWithFinalReply: onChunk receives the complete final reply.
|
||||
func TestOnChunkCalledWithFinalReply(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"equity":500}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/account", "{}"),
|
||||
textReply("Equity: 500 USDT."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
|
||||
var chunks []string
|
||||
reply := a.Run("show equity", func(chunk string) {
|
||||
chunks = append(chunks, chunk)
|
||||
})
|
||||
|
||||
if reply != "Equity: 500 USDT." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
// Should have received ⏳ for the tool call, then the final reply.
|
||||
if len(chunks) < 2 {
|
||||
t.Fatalf("expected at least 2 chunks (⏳ + final), got: %v", chunks)
|
||||
}
|
||||
lastChunk := chunks[len(chunks)-1]
|
||||
if lastChunk != "Equity: 500 USDT." {
|
||||
t.Fatalf("last chunk should be final reply, got: %q", lastChunk)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workflow tests ─────────────────────────────────────────────────────────
|
||||
|
||||
// TestCreateStrategyWorkflow: simulates creating a BTC trend strategy.
|
||||
// Verifies: POST strategy → GET verify → final reply shows strategy info.
|
||||
func TestCreateStrategyWorkflow(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`,
|
||||
"GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
textReply("策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatalf("empty final reply")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullSetupWorkflow: create strategy → verify → create trader → start trader.
|
||||
// This is the "帮我配置策略并跑起来" workflow.
|
||||
func TestFullSetupWorkflow(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
calls[key]++
|
||||
switch key {
|
||||
case "POST /api/strategies":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势"}`)) //nolint:errcheck
|
||||
case "GET /api/strategies/s1":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势","config":{}}`)) //nolint:errcheck
|
||||
case "POST /api/traders":
|
||||
w.Write([]byte(`{"id":"tr1","name":"BTC趋势交易员"}`)) //nolint:errcheck
|
||||
case "POST /api/traders/tr1/start":
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`),
|
||||
toolCall("c4", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("策略和交易员已创建并启动!BTC趋势交易员正在运行。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil)
|
||||
|
||||
if llm.calls != 5 {
|
||||
t.Fatalf("expected 5 LLM calls, got %d", llm.calls)
|
||||
}
|
||||
if calls["POST /api/strategies"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/strategies, got %d", calls["POST /api/strategies"])
|
||||
}
|
||||
if calls["POST /api/traders"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/traders, got %d", calls["POST /api/traders"])
|
||||
}
|
||||
if calls["POST /api/traders/tr1/start"] != 1 {
|
||||
t.Errorf("expected 1 POST /api/traders/tr1/start, got %d", calls["POST /api/traders/tr1/start"])
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatalf("empty final reply")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartExistingTrader: when trader already exists, just start it.
|
||||
func TestStartExistingTrader(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Method + " " + r.URL.Path
|
||||
calls[key]++
|
||||
switch key {
|
||||
case "GET /api/my-traders":
|
||||
w.Write([]byte(`[{"trader_id":"tr1","trader_name":"BTC Trader","is_running":false}]`)) //nolint:errcheck
|
||||
case "POST /api/traders/tr1/start":
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
var port int
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/my-traders", "{}"),
|
||||
toolCall("c2", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("交易员 BTC Trader 已启动。"),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("启动交易员", nil)
|
||||
|
||||
if calls["POST /api/traders/tr1/start"] != 1 {
|
||||
t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"])
|
||||
}
|
||||
if reply != "交易员 BTC Trader 已启动。" {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Safety limit ───────────────────────────────────────────────────────────
|
||||
|
||||
// TestMaxIterations: agent terminates after maxIterations and returns fallback message.
|
||||
func TestMaxIterations(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"ok":true}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
// Always returns another tool call — should hit max iterations.
|
||||
responses := make([]*mcp.LLMResponse, maxIterations+2)
|
||||
for i := range responses {
|
||||
responses[i] = toolCall(fmt.Sprintf("c%d", i), "GET", "/api/account", "{}")
|
||||
}
|
||||
|
||||
llm := &mockLLM{responses: responses}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("loop forever", nil)
|
||||
|
||||
if reply == "" {
|
||||
t.Fatalf("expected a fallback reply, got empty string")
|
||||
}
|
||||
// Agent should have made exactly maxIterations tool-call LLM calls.
|
||||
if llm.calls != maxIterations {
|
||||
t.Fatalf("expected %d LLM calls (max iterations), got %d", maxIterations, llm.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolCallIDPropagated: tool result messages carry the correct ToolCallID.
|
||||
func TestToolCallIDPropagated(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"/api/account": `{"balance":999}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("call-xyz-123", "GET", "/api/account", "{}"),
|
||||
textReply("Balance is 999."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
a.Run("check balance", nil)
|
||||
|
||||
// Find the tool result message and verify ToolCallID matches.
|
||||
found := false
|
||||
for _, msg := range llm.lastMsgs {
|
||||
if msg.Role == "tool" && msg.ToolCallID == "call-xyz-123" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tool result with ToolCallID='call-xyz-123' not found in messages: %+v", llm.lastMsgs)
|
||||
}
|
||||
}
|
||||
88
telegram/agent/apicall.go
Normal file
88
telegram/agent/apicall.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// apiCallTool executes HTTP requests against the NOFX API server.
|
||||
// This is the only tool available to the agent.
|
||||
type apiCallTool struct {
|
||||
baseURL string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// apiRequest holds the arguments decoded from the LLM's api_request tool call.
|
||||
type apiRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
func newAPICallTool(port int, token string) *apiCallTool {
|
||||
return &apiCallTool{
|
||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
token: token,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// execute calls the API and returns the response as a string for LLM consumption.
|
||||
func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
if req.Method == "" || req.Path == "" {
|
||||
return "error: method and path are required"
|
||||
}
|
||||
if !strings.HasPrefix(req.Path, "/") {
|
||||
req.Path = "/" + req.Path
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Method != "GET" && len(req.Body) > 0 {
|
||||
b, err := json.Marshal(req.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error marshaling body: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest(req.Method, t.baseURL+req.Path, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error creating request: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+t.token)
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("API call failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("error reading response: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("Agent api_call: %s %s -> %d", req.Method, req.Path, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Pretty-print JSON for better LLM readability
|
||||
var v any
|
||||
if json.Unmarshal(body, &v) == nil {
|
||||
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
|
||||
return string(pretty)
|
||||
}
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
79
telegram/agent/manager.go
Normal file
79
telegram/agent/manager.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager holds one Agent per Telegram chat ID.
|
||||
// Messages for the same chat are serialized (OpenClaw Lane Queue pattern).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
agents map[int64]*Agent
|
||||
lanes map[int64]chan struct{}
|
||||
apiPort int
|
||||
botToken string
|
||||
userID string
|
||||
getLLM func() mcp.AIClient
|
||||
systemPrompt string
|
||||
}
|
||||
|
||||
// NewManager creates a Manager. Call api.GetAPIDocs() before this and pass the result as apiDocs.
|
||||
// userEmail is the registered email shown to the user when they ask "who am I".
|
||||
// userID is the internal DB UUID used for API authentication.
|
||||
func NewManager(apiPort int, botToken, userEmail, userID string, getLLM func() mcp.AIClient, apiDocs string) *Manager {
|
||||
return &Manager{
|
||||
agents: make(map[int64]*Agent),
|
||||
lanes: make(map[int64]chan struct{}),
|
||||
apiPort: apiPort,
|
||||
botToken: botToken,
|
||||
userID: userID,
|
||||
getLLM: getLLM,
|
||||
systemPrompt: BuildAgentPrompt(apiDocs, userEmail, userID),
|
||||
}
|
||||
}
|
||||
|
||||
// Run processes a message for the given chat ID.
|
||||
// If the same chat is already processing a message, this call blocks until it completes
|
||||
// or the lane wait times out (60 s), whichever comes first.
|
||||
// onChunk is optional — when set, LLM reply chunks are forwarded progressively (SSE streaming).
|
||||
func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) string {
|
||||
a, lane := m.getOrCreate(chatID)
|
||||
select {
|
||||
case lane <- struct{}{}:
|
||||
case <-time.After(60 * time.Second):
|
||||
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
|
||||
return "上一条消息仍在处理中,请稍等片刻后再试。"
|
||||
}
|
||||
defer func() { <-lane }()
|
||||
return a.Run(userMessage, onChunk)
|
||||
}
|
||||
|
||||
// Reset clears memory for the given chat (called on /start).
|
||||
func (m *Manager) Reset(chatID int64) {
|
||||
m.mu.Lock()
|
||||
a, ok := m.agents[chatID]
|
||||
m.mu.Unlock()
|
||||
if ok {
|
||||
a.ResetMemory()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) getOrCreate(chatID int64) (*Agent, chan struct{}) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
a, ok := m.agents[chatID]
|
||||
if !ok {
|
||||
a = New(m.apiPort, m.botToken, m.userID, m.getLLM, m.systemPrompt)
|
||||
m.agents[chatID] = a
|
||||
}
|
||||
lane, ok := m.lanes[chatID]
|
||||
if !ok {
|
||||
lane = make(chan struct{}, 1) // binary semaphore: one message at a time per chat
|
||||
m.lanes[chatID] = lane
|
||||
}
|
||||
return a, lane
|
||||
}
|
||||
97
telegram/agent/prompt.go
Normal file
97
telegram/agent/prompt.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BuildAgentPrompt constructs the full system prompt with live API documentation injected.
|
||||
// apiDocs is the output of api.GetAPIDocs() — reflects all currently registered routes with full schemas.
|
||||
// userEmail is the registered email of the bound user (shown when user asks "who am I").
|
||||
// userID is the internal DB UUID used for API authentication only.
|
||||
func BuildAgentPrompt(apiDocs, userEmail, userID string) string {
|
||||
return fmt.Sprintf(`You are the NOFX quantitative trading system AI assistant.
|
||||
|
||||
## Your Identity
|
||||
- You are operating as: %s
|
||||
- Internal user ID (for API calls only): %s
|
||||
- When asked "which user / account / email" — answer with the email address above
|
||||
- All API calls are made on behalf of this user
|
||||
|
||||
## Tool: api_request
|
||||
Use the api_request tool to call the NOFX REST API:
|
||||
- method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
- path: API path; query params go in the path: /api/positions?trader_id=xxx
|
||||
- body: JSON object (use {} for GET requests)
|
||||
|
||||
## NOFX API Documentation
|
||||
|
||||
%s
|
||||
|
||||
## CRITICAL: Exact ID Rule (read this before every API call)
|
||||
API fields like "ai_model_id", "exchange_id", "strategy_id", "trader_id" require the EXACT "id" value
|
||||
from the corresponding API response. NEVER use "provider", "type", or any other field as a substitute.
|
||||
|
||||
Wrong: {"ai_model_id": "deepseek"} ← "deepseek" is the provider, NOT the id
|
||||
Correct: {"ai_model_id": "abc123_deepseek"} ← full "id" from GET /api/models
|
||||
|
||||
The Account State block at the start of this conversation lists every resource with its exact id.
|
||||
Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess.
|
||||
|
||||
## Behavior Rules
|
||||
1. Reply in the same language the user used (中文→中文, English→English)
|
||||
2. Keep final replies concise — show results, not process
|
||||
3. Ask for ALL missing required info in ONE message — never ask one field at a time
|
||||
4. When user provides enough info, act immediately — no confirmation needed
|
||||
5. Be decisive — infer intent from context, use schema to fill in smart defaults
|
||||
|
||||
## Verification Rule (CRITICAL)
|
||||
After ANY PUT or POST that creates or modifies a resource:
|
||||
1. Immediately GET the resource to read actual saved values
|
||||
2. Show the user the KEY fields they care about from the GET response
|
||||
3. NEVER just say "updated successfully" without showing the actual values
|
||||
4. If saved values look wrong, correct them automatically
|
||||
|
||||
## Error Handling
|
||||
- 400: explain what was wrong, ask user to correct
|
||||
- 404: resource doesn't exist — you may have used the wrong ID format; check the Account State for the exact id
|
||||
- "AI model not enabled": tell user to enable the model first via PUT /api/models
|
||||
- "Exchange not enabled": tell user to enable the exchange first
|
||||
- 5xx: server error, ask user to try again
|
||||
|
||||
## Account State (injected at conversation start)
|
||||
At the start of each new conversation, a [Current Account State] block is provided with:
|
||||
- AI Models: all configured models with their IDs and enabled status
|
||||
- Exchanges: all configured exchanges with their IDs and enabled status
|
||||
- Strategies: all existing strategies with their IDs
|
||||
- Traders: all existing traders with their IDs and running status
|
||||
|
||||
Use this to:
|
||||
- NEVER ask for exchange/model info that is already configured — use the existing IDs directly
|
||||
- Know instantly if the user has 0 or N resources of each type
|
||||
- If only one exchange/model exists and user doesn't specify, use it directly without asking
|
||||
- If multiple exist, list them and ask which one to use
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**Create strategy** (independent from traders):
|
||||
- Never GET trader info just to create a strategy.
|
||||
- POST {"name":"<descriptive name>"} — config is OPTIONAL. Backend applies complete working defaults automatically (ai500 top coins, all indicators, standard risk control). Strategy is immediately usable.
|
||||
- Only include "config" when user explicitly requests custom settings (specific coins, custom leverage, different timeframes).
|
||||
- After POST: GET /api/strategies/:id to verify → show user: name, coin_source.source_type, key risk_control values
|
||||
|
||||
**"帮我配置策略并跑起来" / "create strategy and start" (full setup workflow)**:
|
||||
Execute these steps IN ORDER with NO user confirmation between them:
|
||||
1. POST /api/strategies — body: {"name":"<descriptive name>"} — no config needed, defaults are complete
|
||||
2. GET /api/strategies/:id — verify strategy was saved
|
||||
3. POST /api/traders — create trader: use exchange_id and model_id from Account State (if only one each, use directly); set strategy_id from step 1; set name matching the strategy
|
||||
4. POST /api/traders/:id/start — start the trader
|
||||
5. Final reply: show strategy name, trader name, coin source, confirm running
|
||||
|
||||
**Update strategy config**:
|
||||
1. GET /api/strategies/:id to read current full config
|
||||
2. Modify only what user asked (keep all other fields)
|
||||
3. PUT /api/strategies/:id with complete merged config
|
||||
4. GET /api/strategies/:id to verify → show user actual saved values for changed fields
|
||||
|
||||
**Start/stop existing trader**: From Account State, if only one trader, act directly. If multiple, list and ask.
|
||||
|
||||
**Query data**: Use trader_id from Account State, then query /api/positions?trader_id=xxx or /api/account?trader_id=xxx etc.`, userEmail, userID, apiDocs)
|
||||
}
|
||||
479
telegram/bot.go
Normal file
479
telegram/bot.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"nofx/api"
|
||||
"nofx/config"
|
||||
"nofx/logger"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram/agent"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
)
|
||||
|
||||
// Start initializes and runs the Telegram bot in a blocking supervisor loop.
|
||||
// Supports hot-reload: when a signal is sent on reloadCh, the bot restarts
|
||||
// with the latest token (re-read from DB or env). Must be called as a goroutine from main.go.
|
||||
func Start(cfg *config.Config, st *store.Store, reloadCh <-chan struct{}) {
|
||||
for {
|
||||
token := resolveToken(cfg, st)
|
||||
if token == "" {
|
||||
logger.Info("Telegram bot disabled (no token configured), waiting for reload signal...")
|
||||
<-reloadCh
|
||||
continue
|
||||
}
|
||||
|
||||
stopped := runBot(token, cfg, st)
|
||||
if !stopped {
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-reloadCh:
|
||||
logger.Info("Reloading Telegram bot with new token...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveToken returns the bot token from DB (configured via Web UI).
|
||||
func resolveToken(cfg *config.Config, st *store.Store) string {
|
||||
dbCfg, err := st.TelegramConfig().Get()
|
||||
if err == nil && dbCfg.BotToken != "" {
|
||||
return dbCfg.BotToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// runBot runs the bot until the updates channel closes (clean stop → true) or a fatal error (false).
|
||||
func runBot(token string, cfg *config.Config, st *store.Store) bool {
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
logger.Errorf("Telegram bot failed to start: %v", err)
|
||||
return false
|
||||
}
|
||||
logger.Infof("Telegram bot @%s started", bot.Self.UserName)
|
||||
|
||||
// Allowed chat ID: read from DB binding (0 = unbound, first /start will bind).
|
||||
allowedChatID := int64(0)
|
||||
if id, err := st.TelegramConfig().GetBoundChatID(); err == nil && id != 0 {
|
||||
allowedChatID = id
|
||||
}
|
||||
|
||||
// botUserID / botToken / agents are resolved lazily and refresh when user registers.
|
||||
var (
|
||||
botUserID string
|
||||
botUserEmail string
|
||||
botToken string
|
||||
agents *agent.Manager
|
||||
)
|
||||
|
||||
resolveBotUser := func() bool {
|
||||
users, err := st.User().GetAll()
|
||||
if err != nil || len(users) == 0 {
|
||||
return false
|
||||
}
|
||||
u := users[0]
|
||||
if u.ID == botUserID {
|
||||
return true
|
||||
}
|
||||
newToken, err := agent.GenerateBotToken(u.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate bot JWT for user %s: %v", u.ID, err)
|
||||
return false
|
||||
}
|
||||
prev := botUserID
|
||||
botUserID = u.ID
|
||||
botUserEmail = u.Email
|
||||
botToken = newToken
|
||||
agents = agent.NewManager(cfg.APIServerPort, botToken, botUserEmail, botUserID,
|
||||
func() mcp.AIClient { return newLLMClient(st, botUserID) },
|
||||
api.GetAPIDocs(),
|
||||
)
|
||||
if prev == "" {
|
||||
logger.Infof("Bot: resolved user %s (%s)", botUserID, botUserEmail)
|
||||
} else {
|
||||
logger.Infof("Bot: user changed → %s (%s)", botUserID, botUserEmail)
|
||||
}
|
||||
return true
|
||||
}
|
||||
resolveBotUser()
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
// awaitingLang is set only when the user explicitly runs /lang.
|
||||
awaitingLang := false
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil {
|
||||
continue
|
||||
}
|
||||
chatID := update.Message.Chat.ID
|
||||
text := strings.TrimSpace(update.Message.Text)
|
||||
|
||||
// ── Language selection (triggered only by /lang) ──────────────────────
|
||||
if awaitingLang && chatID == allowedChatID {
|
||||
if lang := parseLangChoice(text); lang != "" {
|
||||
awaitingLang = false
|
||||
st.TelegramConfig().SetLanguage(lang) //nolint:errcheck
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
} else {
|
||||
sendMarkdownMsg(bot, chatID, langMenuMsg())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /start ────────────────────────────────────────────────────────────
|
||||
if text == "/start" {
|
||||
resolveBotUser()
|
||||
if botUserID == "" {
|
||||
sendMsg(bot, chatID,
|
||||
"No account found.\nOpen the web dashboard to register, then send /start.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
username := update.Message.From.UserName
|
||||
if err := st.TelegramConfig().BindUser(chatID, "@"+username); err != nil {
|
||||
logger.Errorf("Failed to bind Telegram user: %v", err)
|
||||
sendMsg(bot, chatID, "Binding failed. Please try again.")
|
||||
continue
|
||||
}
|
||||
allowedChatID = chatID
|
||||
logger.Infof("Telegram bound to @%s (chatID: %d)", username, chatID)
|
||||
} else if chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "This bot is already bound to another account.")
|
||||
continue
|
||||
} else {
|
||||
agents.Reset(chatID)
|
||||
}
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /lang ─────────────────────────────────────────────────────────────
|
||||
if text == "/lang" {
|
||||
awaitingLang = true
|
||||
sendMarkdownMsg(bot, chatID, langMenuMsg())
|
||||
continue
|
||||
}
|
||||
|
||||
// ── /help ─────────────────────────────────────────────────────────────
|
||||
if text == "/help" {
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
sendMarkdownMsg(bot, chatID, helpMsg(lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Access control ────────────────────────────────────────────────────
|
||||
if allowedChatID != 0 && chatID != allowedChatID {
|
||||
sendMsg(bot, chatID, "Unauthorized.")
|
||||
continue
|
||||
}
|
||||
if allowedChatID == 0 {
|
||||
sendMsg(bot, chatID, "Send /start first.")
|
||||
continue
|
||||
}
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Refresh user before every AI call ────────────────────────────────
|
||||
resolveBotUser()
|
||||
if botUserID == "" {
|
||||
sendMsg(bot, chatID, "No account found. Open the web dashboard to register.")
|
||||
continue
|
||||
}
|
||||
|
||||
lang := st.TelegramConfig().GetLanguage()
|
||||
|
||||
// ── Guard: show status if not ready for trading ───────────────────────
|
||||
if newLLMClient(st, botUserID) == nil {
|
||||
sendMarkdownMsg(bot, chatID, statusMsg(st, botUserID, cfg.APIServerPort, lang))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── AI agent ─────────────────────────────────────────────────────────
|
||||
go func(chatID int64, text string) {
|
||||
sent, err := bot.Send(tgbotapi.NewMessage(chatID, "⏳"))
|
||||
placeholderID := 0
|
||||
if err == nil {
|
||||
placeholderID = sent.MessageID
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lastEdit time.Time
|
||||
)
|
||||
onChunk := func(accumulated string) {
|
||||
if placeholderID == 0 {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if accumulated != "⏳" && time.Since(lastEdit) < time.Second {
|
||||
return
|
||||
}
|
||||
lastEdit = time.Now()
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, accumulated)
|
||||
bot.Send(edit) //nolint:errcheck
|
||||
}
|
||||
|
||||
reply := agents.Run(chatID, text, onChunk)
|
||||
|
||||
if placeholderID != 0 {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
edit.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(edit); err != nil {
|
||||
edit2 := tgbotapi.NewEditMessageText(chatID, placeholderID, reply)
|
||||
bot.Send(edit2) //nolint:errcheck
|
||||
}
|
||||
} else {
|
||||
msg := tgbotapi.NewMessage(chatID, reply)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
msg.ParseMode = ""
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}(chatID, text)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func sendMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(msg) //nolint:errcheck
|
||||
}
|
||||
|
||||
func sendMarkdownMsg(bot *tgbotapi.BotAPI, chatID int64, text string) {
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
msg.ParseMode = "Markdown"
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
plain := tgbotapi.NewMessage(chatID, text)
|
||||
bot.Send(plain) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLM client ───────────────────────────────────────────────────────────────
|
||||
|
||||
func newLLMClient(st *store.Store, userID string) mcp.AIClient {
|
||||
// 1. Prefer the model explicitly configured for Telegram (Settings → Telegram → AI Model)
|
||||
if tgCfg, err := st.TelegramConfig().Get(); err == nil && tgCfg.ModelID != "" {
|
||||
if model, err := st.AIModel().Get(userID, tgCfg.ModelID); err == nil && model.Enabled {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
if isUSDCProvider(model.Provider) {
|
||||
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
|
||||
} else {
|
||||
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to first enabled model
|
||||
if model, err := st.AIModel().GetDefault(userID); err == nil {
|
||||
apiKey := string(model.APIKey)
|
||||
if apiKey != "" {
|
||||
client := clientForProvider(model.Provider)
|
||||
client.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
if isUSDCProvider(model.Provider) {
|
||||
logger.Infof("Telegram agent: provider=%s (USDC payment) user=%s", model.Provider, userID)
|
||||
} else {
|
||||
logger.Infof("Telegram agent: provider=%s user=%s", model.Provider, userID)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Environment variable fallback
|
||||
for _, pair := range []struct{ provider, key, url string }{
|
||||
{"deepseek", os.Getenv("DEEPSEEK_API_KEY"), mcp.DefaultDeepSeekBaseURL},
|
||||
{"openai", os.Getenv("OPENAI_API_KEY"), ""},
|
||||
{"claude", os.Getenv("ANTHROPIC_API_KEY"), ""},
|
||||
} {
|
||||
if pair.key != "" {
|
||||
client := clientForProvider(pair.provider)
|
||||
client.SetAPIKey(pair.key, pair.url, "")
|
||||
return client
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUSDCProvider returns true for providers that pay per call with USDC (x402 protocol).
|
||||
func isUSDCProvider(provider string) bool {
|
||||
return provider == "blockrun-base" || provider == "blockrun-sol" || provider == "claw402"
|
||||
}
|
||||
|
||||
func clientForProvider(provider string) mcp.AIClient {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return mcp.NewOpenAIClient()
|
||||
case "deepseek":
|
||||
return mcp.NewDeepSeekClient()
|
||||
case "claude":
|
||||
return mcp.NewClaudeClient()
|
||||
case "qwen":
|
||||
return mcp.NewQwenClient()
|
||||
case "kimi":
|
||||
return mcp.NewKimiClient()
|
||||
case "grok":
|
||||
return mcp.NewGrokClient()
|
||||
case "gemini":
|
||||
return mcp.NewGeminiClient()
|
||||
case "minimax":
|
||||
return mcp.NewMiniMaxClient()
|
||||
case "blockrun-base":
|
||||
return mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
return mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
return mcp.NewClaw402Client()
|
||||
default:
|
||||
return mcp.NewDeepSeekClient()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status message ────────────────────────────────────────────────────────────
|
||||
|
||||
// statusMsg is the single entry-point message shown after /start.
|
||||
// It checks what's configured and shows either a setup prompt or the ready state.
|
||||
func statusMsg(st *store.Store, userID string, apiPort int, lang string) string {
|
||||
webURL := "http://localhost:3000"
|
||||
|
||||
// Determine what's missing.
|
||||
hasModel := false
|
||||
if _, err := st.AIModel().GetDefault(userID); err == nil {
|
||||
hasModel = true
|
||||
}
|
||||
|
||||
hasExchange := false
|
||||
if exchanges, err := st.Exchange().List(userID); err == nil {
|
||||
for _, e := range exchanges {
|
||||
if e.Enabled {
|
||||
hasExchange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasModel || !hasExchange {
|
||||
missing := ""
|
||||
if lang == "zh" {
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI 模型 → 设置 → AI 模型 → 添加"
|
||||
}
|
||||
if !hasExchange {
|
||||
missing += "\n❌ 交易所 → 设置 → 交易所 → 添加"
|
||||
}
|
||||
return "⚙️ *需要完成初始配置*\n\n打开 Web 管理界面完成配置:\n→ " + webURL + "\n" + missing + "\n\n配置完成后发送 /start"
|
||||
}
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI Model → Settings → AI Models → Add"
|
||||
}
|
||||
if !hasExchange {
|
||||
missing += "\n❌ Exchange → Settings → Exchanges → Add"
|
||||
}
|
||||
return "⚙️ *Setup required*\n\nOpen the web dashboard to complete setup:\n→ " + webURL + "\n" + missing + "\n\nSend /start when done."
|
||||
}
|
||||
|
||||
// All configured — show ready state.
|
||||
if lang == "zh" {
|
||||
return `✅ *NOFX 就绪,开始交易吧!*
|
||||
|
||||
直接告诉我你想做什么:
|
||||
|
||||
📊 "查看我的持仓"
|
||||
💰 "账户余额多少"
|
||||
🤖 "帮我创建 BTC 趋势策略并启动"
|
||||
⏹ "停止所有交易员"
|
||||
|
||||
/help 查看更多 · /lang 切换语言`
|
||||
}
|
||||
return `✅ *NOFX is ready!*
|
||||
|
||||
Just tell me what you want:
|
||||
|
||||
📊 "Show my positions"
|
||||
💰 "What's my balance?"
|
||||
🤖 "Create a BTC trend strategy and start it"
|
||||
⏹ "Stop all traders"
|
||||
|
||||
/help for more · /lang to change language`
|
||||
}
|
||||
|
||||
// ── Language ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func langMenuMsg() string {
|
||||
return "🌐 *Choose your language*\n\n1 — English\n2 — 中文\n\nReply with 1 or 2"
|
||||
}
|
||||
|
||||
func parseLangChoice(text string) string {
|
||||
switch strings.TrimSpace(text) {
|
||||
case "1", "en", "EN", "English", "english":
|
||||
return "en"
|
||||
case "2", "zh", "ZH", "中文", "chinese", "Chinese":
|
||||
return "zh"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Help ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func helpMsg(lang string) string {
|
||||
if lang == "zh" {
|
||||
return `*NOFX 使用指南*
|
||||
|
||||
*查询*
|
||||
• "查看我的持仓"
|
||||
• "账户余额多少"
|
||||
• "列出我的交易员"
|
||||
|
||||
*创建 & 启动*
|
||||
• "帮我创建 BTC 趋势策略并跑起来"
|
||||
• "保守型策略,只交易 BTC 和 ETH"
|
||||
|
||||
*控制*
|
||||
• "启动交易员"
|
||||
• "暂停交易员"
|
||||
• "停止所有交易"
|
||||
|
||||
*命令*
|
||||
/start — 刷新状态
|
||||
/lang — 切换语言
|
||||
/help — 帮助`
|
||||
}
|
||||
return `*NOFX Help*
|
||||
|
||||
*Query*
|
||||
• "Show my positions"
|
||||
• "What's my balance?"
|
||||
• "List my traders"
|
||||
|
||||
*Create & start*
|
||||
• "Create a BTC trend strategy and start it"
|
||||
• "Conservative strategy, BTC and ETH only"
|
||||
|
||||
*Control*
|
||||
• "Start trader"
|
||||
• "Stop trader"
|
||||
• "Stop all trading"
|
||||
|
||||
*Commands*
|
||||
/start — refresh status
|
||||
/lang — change language
|
||||
/help — show this`
|
||||
}
|
||||
105
telegram/session/memory.go
Normal file
105
telegram/session/memory.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionThresholdTokens = 3000
|
||||
charsPerToken = 3 // rough estimate for token counting
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string // "user" or "assistant"
|
||||
Content string
|
||||
}
|
||||
|
||||
// Memory manages conversation history with automatic compaction.
|
||||
// Inspired by openclaw's compaction pattern:
|
||||
// when ShortTerm exceeds threshold, LLM silently summarizes it into LongTerm.
|
||||
type Memory struct {
|
||||
LongTerm string // Durable summary (survives compaction, user never sees this happen)
|
||||
ShortTerm []Message // Recent conversation (cleared on compaction)
|
||||
llm mcp.AIClient
|
||||
}
|
||||
|
||||
func NewMemory(llm mcp.AIClient) *Memory {
|
||||
return &Memory{llm: llm}
|
||||
}
|
||||
|
||||
// Add appends a message and triggers compaction if threshold exceeded
|
||||
func (m *Memory) Add(role, content string) {
|
||||
m.ShortTerm = append(m.ShortTerm, Message{Role: role, Content: content})
|
||||
if m.estimateTokens() > compactionThresholdTokens {
|
||||
m.compact()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildContext returns context string for the agent's conversation history.
|
||||
func (m *Memory) BuildContext() string {
|
||||
var sb strings.Builder
|
||||
if m.LongTerm != "" {
|
||||
sb.WriteString("[Summary of earlier conversation]\n")
|
||||
sb.WriteString(m.LongTerm)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
if len(m.ShortTerm) > 0 {
|
||||
sb.WriteString("[Recent conversation]\n")
|
||||
for _, msg := range m.ShortTerm {
|
||||
sb.WriteString(fmt.Sprintf("%s: %s\n", msg.Role, msg.Content))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Reset clears short-term history (LongTerm preserved intentionally)
|
||||
func (m *Memory) Reset() {
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
|
||||
// ResetFull clears everything including long-term memory
|
||||
func (m *Memory) ResetFull() {
|
||||
m.ShortTerm = []Message{}
|
||||
m.LongTerm = ""
|
||||
}
|
||||
|
||||
func (m *Memory) estimateTokens() int {
|
||||
total := len(m.LongTerm)
|
||||
for _, msg := range m.ShortTerm {
|
||||
total += len(msg.Content)
|
||||
}
|
||||
return total / charsPerToken
|
||||
}
|
||||
|
||||
// compact summarizes short-term history into long-term memory.
|
||||
// This runs silently - the user never sees it happen.
|
||||
// If LLM call fails, short-term is preserved as-is (no data loss).
|
||||
func (m *Memory) compact() {
|
||||
if m.llm == nil || len(m.ShortTerm) == 0 {
|
||||
return
|
||||
}
|
||||
history := m.BuildContext()
|
||||
systemPrompt := `You are a conversation summarizer. Compress the following trading assistant conversation into a concise summary.
|
||||
|
||||
Must preserve:
|
||||
- What the user is configuring (strategy/exchange/model/trader)
|
||||
- Confirmed parameters (trading pairs, leverage, stop loss, indicators, etc.)
|
||||
- Pending or missing parameters
|
||||
- User preferences and requirements
|
||||
|
||||
Output: plain text summary, under 200 words.`
|
||||
|
||||
summary, err := m.llm.CallWithMessages(systemPrompt, history)
|
||||
if err != nil {
|
||||
// Compaction failed: keep short-term as-is, never lose user data
|
||||
return
|
||||
}
|
||||
if m.LongTerm != "" {
|
||||
m.LongTerm = m.LongTerm + "\n" + summary
|
||||
} else {
|
||||
m.LongTerm = summary
|
||||
}
|
||||
m.ShortTerm = []Message{}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package aster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package aster
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// AsterTrader Aster trading platform implementation
|
||||
@@ -1295,14 +1296,14 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
|
||||
// GetClosedPnL gets recent closing trades from Aster
|
||||
// Note: Aster does NOT have a position history API, only trade history.
|
||||
// This returns individual closing trades for real-time position closure detection.
|
||||
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []ClosedPnLRecord
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
@@ -1330,7 +1331,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLR
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, ClosedPnLRecord{
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -1366,7 +1367,7 @@ type AsterTradeRecord struct {
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Aster
|
||||
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
@@ -1381,24 +1382,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
||||
body, err := t.request("GET", "/fapi/v3/userTrades", params)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Aster userTrades API error: %v", err)
|
||||
return []TradeRecord{}, nil
|
||||
return []types.TradeRecord{}, nil
|
||||
}
|
||||
|
||||
var asterTrades []AsterTradeRecord
|
||||
if err := json.Unmarshal(body, &asterTrades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
|
||||
return []TradeRecord{}, nil
|
||||
return []types.TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []TradeRecord
|
||||
var result []types.TradeRecord
|
||||
for _, at := range asterTrades {
|
||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(at.Qty, 64)
|
||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||
|
||||
trade := TradeRecord{
|
||||
trade := types.TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: at.Side,
|
||||
@@ -1416,7 +1417,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
@@ -1442,13 +1443,13 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
var result []types.OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
@@ -1466,7 +1467,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
@@ -1532,7 +1533,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &LimitOrderResult{
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package aster
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -19,7 +21,7 @@ import (
|
||||
// AsterTraderTestSuite Aster trader test suite
|
||||
// Inherits TraderTestSuite and adds Aster specific mock logic
|
||||
type AsterTraderTestSuite struct {
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -191,7 +193,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
||||
privateKey, _ := crypto.GenerateKey()
|
||||
|
||||
// Create mock trader using mock server's URL
|
||||
trader := &AsterTrader{
|
||||
traderInstance := &AsterTrader{
|
||||
ctx: context.Background(),
|
||||
user: "0x1234567890123456789012345678901234567890",
|
||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
@@ -202,7 +204,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &AsterTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -224,7 +226,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
|
||||
|
||||
// TestAsterTrader_InterfaceCompliance tests interface compliance
|
||||
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*AsterTrader)(nil)
|
||||
var _ types.Trader = (*AsterTrader)(nil)
|
||||
}
|
||||
|
||||
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
|
||||
@@ -277,21 +279,21 @@ func TestNewAsterTrader(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
||||
at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
||||
|
||||
if tt.wantError {
|
||||
assert.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
assert.Nil(t, trader)
|
||||
assert.Nil(t, at)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, trader)
|
||||
if trader != nil {
|
||||
assert.Equal(t, tt.user, trader.user)
|
||||
assert.Equal(t, tt.signer, trader.signer)
|
||||
assert.NotNil(t, trader.privateKey)
|
||||
assert.NotNil(t, at)
|
||||
if at != nil {
|
||||
assert.Equal(t, tt.user, at.user)
|
||||
assert.Equal(t, tt.signer, at.signer)
|
||||
assert.NotNil(t, at.privateKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -4,12 +4,22 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/kernel"
|
||||
"nofx/experience"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/trader/aster"
|
||||
"nofx/trader/binance"
|
||||
"nofx/trader/bitget"
|
||||
"nofx/trader/bybit"
|
||||
"nofx/trader/gate"
|
||||
"nofx/trader/hyperliquid"
|
||||
"nofx/trader/indodax"
|
||||
"nofx/trader/kucoin"
|
||||
"nofx/trader/lighter"
|
||||
"nofx/trader/okx"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -23,7 +33,7 @@ type AutoTraderConfig struct {
|
||||
AIModel string // AI model: "qwen" or "deepseek"
|
||||
|
||||
// Trading platform selection
|
||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
|
||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
|
||||
ExchangeID string // Exchange account UUID (for multi-account support)
|
||||
|
||||
// Binance API configuration
|
||||
@@ -44,10 +54,24 @@ type AutoTraderConfig struct {
|
||||
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
|
||||
|
||||
// Aster configuration
|
||||
AsterUser string // Aster main wallet address
|
||||
@@ -177,6 +201,26 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
|
||||
|
||||
case "minimax":
|
||||
mcpClient = mcp.NewMiniMaxClient()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using MiniMax AI", config.Name)
|
||||
|
||||
case "blockrun-base":
|
||||
mcpClient = mcp.NewBlockRunBaseClient()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using BlockRun (Base Wallet) AI", config.Name)
|
||||
|
||||
case "blockrun-sol":
|
||||
mcpClient = mcp.NewBlockRunSolClient()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using BlockRun (Solana Wallet) AI", config.Name)
|
||||
|
||||
case "claw402":
|
||||
mcpClient = mcp.NewClaw402Client()
|
||||
mcpClient.SetAPIKey(config.CustomAPIKey, "", config.CustomModelName)
|
||||
logger.Infof("🤖 [%s] Using Claw402 (Base USDC) AI", config.Name)
|
||||
|
||||
case "qwen":
|
||||
mcpClient = mcp.NewQwenClient()
|
||||
apiKey := config.QwenKey
|
||||
@@ -224,25 +268,31 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
switch config.Exchange {
|
||||
case "binance":
|
||||
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
|
||||
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||||
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||||
case "bybit":
|
||||
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
|
||||
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||||
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||||
case "okx":
|
||||
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
|
||||
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||||
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||||
case "bitget":
|
||||
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
|
||||
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
||||
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
||||
case "gate":
|
||||
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
|
||||
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
|
||||
case "kucoin":
|
||||
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
|
||||
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
|
||||
case "hyperliquid":
|
||||
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
|
||||
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
|
||||
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
|
||||
}
|
||||
case "aster":
|
||||
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
|
||||
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||||
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
|
||||
}
|
||||
@@ -254,7 +304,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
}
|
||||
|
||||
// Lighter only supports mainnet (testnet disabled)
|
||||
trader, err = NewLighterTraderV2(
|
||||
trader, err = lighter.NewLighterTraderV2(
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterAPIKeyIndex,
|
||||
@@ -264,6 +314,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||||
}
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||||
case "indodax":
|
||||
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
||||
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||
}
|
||||
@@ -363,7 +416,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Lighter order sync if using Lighter exchange
|
||||
if at.exchange == "lighter" {
|
||||
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
|
||||
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
|
||||
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -371,7 +424,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Hyperliquid order sync if using Hyperliquid exchange
|
||||
if at.exchange == "hyperliquid" {
|
||||
if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil {
|
||||
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
|
||||
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -379,7 +432,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Bybit order sync if using Bybit exchange
|
||||
if at.exchange == "bybit" {
|
||||
if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil {
|
||||
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
|
||||
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -387,7 +440,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start OKX order sync if using OKX exchange
|
||||
if at.exchange == "okx" {
|
||||
if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil {
|
||||
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
|
||||
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -395,7 +448,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Bitget order sync if using Bitget exchange
|
||||
if at.exchange == "bitget" {
|
||||
if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil {
|
||||
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
|
||||
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -403,7 +456,7 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Aster order sync if using Aster exchange
|
||||
if at.exchange == "aster" {
|
||||
if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil {
|
||||
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
|
||||
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
@@ -411,12 +464,28 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Start Binance order sync if using Binance exchange
|
||||
if at.exchange == "binance" {
|
||||
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
|
||||
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
|
||||
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Start Gate order sync if using Gate exchange
|
||||
if at.exchange == "gate" {
|
||||
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
|
||||
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Start KuCoin order sync if using KuCoin exchange
|
||||
if at.exchange == "kucoin" {
|
||||
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
|
||||
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -534,15 +603,26 @@ 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)
|
||||
@@ -821,14 +901,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
}
|
||||
|
||||
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
||||
var candidateCoins []kernel.CandidateCoin
|
||||
if at.strategyEngine == nil {
|
||||
return nil, fmt.Errorf("trader has no strategy engine configured")
|
||||
}
|
||||
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
|
||||
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
|
||||
} else {
|
||||
coins, err := at.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Calculate total P&L
|
||||
totalPnL := totalEquity - at.initialBalance
|
||||
@@ -1050,7 +1135,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
|
||||
}
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1167,7 +1252,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
|
||||
}
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1266,7 +1351,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
|
||||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1330,7 +1415,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
|
||||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
marketData, err := market.Get(decision.Symbol)
|
||||
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1926,7 +2011,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
||||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||||
switch at.exchange {
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
|
||||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||||
return
|
||||
}
|
||||
@@ -2267,4 +2352,3 @@ func getSideFromAction(action string) string {
|
||||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
return at.trader.GetOpenOrders(symbol)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ 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
|
||||
@@ -73,6 +78,7 @@ func NewGridState(config *store.GridStrategyConfig) *GridState {
|
||||
Config: config,
|
||||
Levels: make([]kernel.GridLevelInfo, 0),
|
||||
OrderBook: make(map[string]int),
|
||||
CurrentDirection: market.GridDirectionNeutral,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +331,17 @@ func (at *AutoTrader) checkBoxBreakout() error {
|
||||
}
|
||||
|
||||
// Take action based on breakout level
|
||||
action := getBreakoutAction(breakoutLevel)
|
||||
// Use direction-aware action if enabled
|
||||
enableDirectionAdjust := gridConfig.EnableDirectionAdjust
|
||||
action := getBreakoutActionWithDirection(breakoutLevel, enableDirectionAdjust)
|
||||
|
||||
// If direction adjustment action, determine the new direction
|
||||
if action == BreakoutActionAdjustDirection {
|
||||
box, _ := market.GetBoxData(gridConfig.Symbol)
|
||||
newDirection := determineGridDirection(box, at.gridState.CurrentDirection, breakoutLevel, direction)
|
||||
return at.executeDirectionAdjustment(newDirection)
|
||||
}
|
||||
|
||||
return at.executeBreakoutAction(action)
|
||||
}
|
||||
|
||||
@@ -358,11 +374,38 @@ func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
|
||||
logger.Infof("Failed to cancel orders: %v", err)
|
||||
}
|
||||
return at.closeAllPositions()
|
||||
|
||||
case BreakoutActionAdjustDirection:
|
||||
// Direction adjustment is handled separately via executeDirectionAdjustment
|
||||
// This case should not be reached, but handle gracefully
|
||||
logger.Infof("Direction adjustment action received via executeBreakoutAction")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDirectionAdjustment handles grid direction changes based on box breakout
|
||||
func (at *AutoTrader) executeDirectionAdjustment(newDirection market.GridDirection) error {
|
||||
at.gridState.mu.RLock()
|
||||
oldDirection := at.gridState.CurrentDirection
|
||||
at.gridState.mu.RUnlock()
|
||||
|
||||
if oldDirection == newDirection {
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
logger.Infof("[Grid] Direction adjustment: %s → %s", oldDirection, newDirection)
|
||||
|
||||
// Cancel existing orders before adjusting
|
||||
if err := at.cancelAllGridOrders(); err != nil {
|
||||
logger.Warnf("[Grid] Failed to cancel orders during direction adjustment: %v", err)
|
||||
}
|
||||
|
||||
// Apply the new direction
|
||||
return at.adjustGridDirection(newDirection)
|
||||
}
|
||||
|
||||
// closeAllPositions closes all open positions for the grid symbol
|
||||
func (at *AutoTrader) closeAllPositions() error {
|
||||
gridConfig := at.config.StrategyConfig.GridConfig
|
||||
@@ -410,10 +453,16 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
||||
breakoutLevel := at.gridState.BreakoutLevel
|
||||
isPaused := at.gridState.IsPaused
|
||||
positionReduction := at.gridState.PositionReductionPct
|
||||
currentDirection := at.gridState.CurrentDirection
|
||||
at.gridState.mu.RUnlock()
|
||||
|
||||
// Only check if we had a breakout
|
||||
if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
|
||||
// Only check if we had a breakout or non-neutral direction
|
||||
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) ||
|
||||
positionReduction != 0 ||
|
||||
isPaused ||
|
||||
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
|
||||
|
||||
if !needsRecoveryCheck {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -436,6 +485,18 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
||||
at.gridState.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check for direction recovery toward neutral (if direction adjustment is enabled)
|
||||
if gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral {
|
||||
if shouldRecoverDirection(box, currentDirection) {
|
||||
newDirection := determineRecoveryDirection(box.CurrentPrice, box, currentDirection)
|
||||
if newDirection != currentDirection {
|
||||
logger.Infof("[Grid] Direction recovery: %s → %s (price back in short box)",
|
||||
currentDirection, newDirection)
|
||||
at.adjustGridDirection(newDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -570,6 +631,128 @@ func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.G
|
||||
}
|
||||
|
||||
at.gridState.Levels = levels
|
||||
|
||||
// Apply direction-based side assignment if enabled
|
||||
if config.EnableDirectionAdjust {
|
||||
at.applyGridDirection(currentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// applyGridDirection adjusts grid level sides based on the current direction
|
||||
// This redistributes buy/sell levels according to the direction bias ratio
|
||||
func (at *AutoTrader) applyGridDirection(currentPrice float64) {
|
||||
config := at.gridState.Config
|
||||
direction := at.gridState.CurrentDirection
|
||||
|
||||
// Get bias ratio from config, default to 0.7 (70%/30%)
|
||||
biasRatio := config.DirectionBiasRatio
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7
|
||||
}
|
||||
|
||||
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
||||
|
||||
// Calculate how many levels should be buy vs sell based on direction
|
||||
totalLevels := len(at.gridState.Levels)
|
||||
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
||||
|
||||
// For neutral: use price-based assignment (buy below, sell above)
|
||||
if direction == market.GridDirectionNeutral {
|
||||
for i := range at.gridState.Levels {
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For long/long_bias: more buy levels
|
||||
// For short/short_bias: more sell levels
|
||||
switch direction {
|
||||
case market.GridDirectionLong:
|
||||
// 100% buy - all levels are buy
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
}
|
||||
|
||||
case market.GridDirectionShort:
|
||||
// 100% sell - all levels are sell
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
|
||||
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
||||
// Assign sides based on position relative to current price
|
||||
// For long_bias: keep all below as buy, convert some above to buy
|
||||
// For short_bias: keep all above as sell, convert some below to sell
|
||||
buyCount := 0
|
||||
sellCount := 0
|
||||
|
||||
for i := range at.gridState.Levels {
|
||||
needMoreBuys := buyCount < targetBuyLevels
|
||||
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
||||
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
// Level below or at current price
|
||||
if needMoreBuys {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
}
|
||||
} else {
|
||||
// Level above current price
|
||||
if needMoreSells && direction == market.GridDirectionShortBias {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else if needMoreSells {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("[Grid] Applied direction %s: buy_ratio=%.0f%%, levels reconfigured",
|
||||
direction, buyRatio*100)
|
||||
}
|
||||
|
||||
// adjustGridDirection handles runtime direction adjustment when breakout is detected
|
||||
func (at *AutoTrader) adjustGridDirection(newDirection market.GridDirection) error {
|
||||
at.gridState.mu.Lock()
|
||||
defer at.gridState.mu.Unlock()
|
||||
|
||||
oldDirection := at.gridState.CurrentDirection
|
||||
if oldDirection == newDirection {
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
at.gridState.CurrentDirection = newDirection
|
||||
at.gridState.DirectionChangedAt = time.Now()
|
||||
at.gridState.DirectionChangeCount++
|
||||
|
||||
logger.Infof("[Grid] Direction changed: %s → %s (change count: %d)",
|
||||
oldDirection, newDirection, at.gridState.DirectionChangeCount)
|
||||
|
||||
// Get current price for recalculation
|
||||
currentPrice, err := at.trader.GetMarketPrice(at.gridState.Config.Symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// Reapply direction to grid levels
|
||||
at.applyGridDirection(currentPrice)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunGridCycle executes one grid trading cycle
|
||||
@@ -1370,6 +1553,85 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s
|
||||
}
|
||||
|
||||
at.gridState.Levels = levels
|
||||
|
||||
// Apply direction-based side assignment if enabled (note: caller holds lock)
|
||||
if config.EnableDirectionAdjust {
|
||||
at.applyGridDirectionLocked(currentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// applyGridDirectionLocked adjusts grid level sides based on the current direction (caller must hold lock)
|
||||
func (at *AutoTrader) applyGridDirectionLocked(currentPrice float64) {
|
||||
config := at.gridState.Config
|
||||
direction := at.gridState.CurrentDirection
|
||||
|
||||
// Get bias ratio from config, default to 0.7 (70%/30%)
|
||||
biasRatio := config.DirectionBiasRatio
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7
|
||||
}
|
||||
|
||||
buyRatio, _ := direction.GetBuySellRatio(biasRatio)
|
||||
|
||||
// For neutral: use price-based assignment (buy below, sell above)
|
||||
if direction == market.GridDirectionNeutral {
|
||||
for i := range at.gridState.Levels {
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
totalLevels := len(at.gridState.Levels)
|
||||
targetBuyLevels := int(float64(totalLevels) * buyRatio)
|
||||
|
||||
switch direction {
|
||||
case market.GridDirectionLong:
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
}
|
||||
|
||||
case market.GridDirectionShort:
|
||||
for i := range at.gridState.Levels {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
}
|
||||
|
||||
case market.GridDirectionLongBias, market.GridDirectionShortBias:
|
||||
buyCount := 0
|
||||
sellCount := 0
|
||||
|
||||
for i := range at.gridState.Levels {
|
||||
needMoreBuys := buyCount < targetBuyLevels
|
||||
needMoreSells := sellCount < (totalLevels - targetBuyLevels)
|
||||
|
||||
if at.gridState.Levels[i].Price <= currentPrice {
|
||||
if needMoreBuys {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
}
|
||||
} else {
|
||||
if needMoreSells && direction == market.GridDirectionShortBias {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else if needMoreBuys && direction == market.GridDirectionLongBias {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
} else if needMoreSells {
|
||||
at.gridState.Levels[i].Side = "sell"
|
||||
sellCount++
|
||||
} else {
|
||||
at.gridState.Levels[i].Side = "buy"
|
||||
buyCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GridRiskInfo contains risk information for frontend display
|
||||
@@ -1397,6 +1659,11 @@ type GridRiskInfo struct {
|
||||
|
||||
BreakoutLevel string `json:"breakout_level"`
|
||||
BreakoutDirection string `json:"breakout_direction"`
|
||||
|
||||
// Grid direction
|
||||
CurrentGridDirection string `json:"current_grid_direction"`
|
||||
DirectionChangeCount int `json:"direction_change_count"`
|
||||
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||||
}
|
||||
|
||||
// GetGridRiskInfo returns current risk information for frontend display
|
||||
@@ -1513,6 +1780,10 @@ func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
|
||||
|
||||
BreakoutLevel: at.gridState.BreakoutLevel,
|
||||
BreakoutDirection: at.gridState.BreakoutDirection,
|
||||
|
||||
CurrentGridDirection: string(at.gridState.CurrentDirection),
|
||||
DirectionChangeCount: at.gridState.DirectionChangeCount,
|
||||
EnableDirectionAdjust: gridConfig.EnableDirectionAdjust,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/hook"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -718,7 +719,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
@@ -770,7 +771,7 @@ func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResu
|
||||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
@@ -896,8 +897,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
var result []types.OpenOrder
|
||||
|
||||
// 1. Get legacy open orders
|
||||
orders, err := t.client.NewListOpenOrdersService().
|
||||
@@ -913,7 +914,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
@@ -936,7 +937,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||||
Symbol: algoOrder.Symbol,
|
||||
Side: string(algoOrder.Side),
|
||||
@@ -1247,14 +1248,14 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin
|
||||
// Note: Binance does NOT have a position history API, only trade history.
|
||||
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
|
||||
// NOT suitable for historical position reconstruction - use only for matching recent closures.
|
||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
|
||||
var records []ClosedPnLRecord
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue // Skip opening trades
|
||||
@@ -1283,7 +1284,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, ClosedPnLRecord{
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -1304,7 +1305,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn
|
||||
|
||||
// GetTrades retrieves trade history from Binance Futures using Income API
|
||||
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
|
||||
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1322,7 +1323,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
||||
return nil, fmt.Errorf("failed to get income history: %w", err)
|
||||
}
|
||||
|
||||
var trades []TradeRecord
|
||||
var trades []types.TradeRecord
|
||||
for _, income := range incomes {
|
||||
pnl, _ := strconv.ParseFloat(income.Income, 64)
|
||||
if pnl == 0 {
|
||||
@@ -1331,7 +1332,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
||||
|
||||
// Income API doesn't provide full trade details, create a minimal record
|
||||
// This is mainly used for detecting recent closures, not historical reconstruction
|
||||
trade := TradeRecord{
|
||||
trade := types.TradeRecord{
|
||||
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||
Symbol: income.Symbol,
|
||||
RealizedPnL: pnl,
|
||||
@@ -1347,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
||||
|
||||
// GetTradesForSymbol retrieves trade history for a specific symbol
|
||||
// This is more reliable than using Income API which may have delays
|
||||
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1364,14 +1365,14 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
||||
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
|
||||
}
|
||||
|
||||
var trades []TradeRecord
|
||||
var trades []types.TradeRecord
|
||||
for _, at := range accountTrades {
|
||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||
|
||||
trade := TradeRecord{
|
||||
trade := types.TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: string(at.Side),
|
||||
@@ -1390,7 +1391,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
||||
|
||||
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
|
||||
// This is used for incremental sync - only fetch new trades since last sync
|
||||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
|
||||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1407,14 +1408,14 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
|
||||
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
|
||||
}
|
||||
|
||||
var trades []TradeRecord
|
||||
var trades []types.TradeRecord
|
||||
for _, at := range accountTrades {
|
||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||
|
||||
trade := TradeRecord{
|
||||
trade := types.TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: string(at.Side),
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"github.com/adshao/go-binance/v2/futures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
// BinanceFuturesTestSuite Binance Futures trader test suite
|
||||
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
|
||||
type BinanceFuturesTestSuite struct {
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -270,13 +272,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
|
||||
client.HTTPClient = mockServer.Client()
|
||||
|
||||
// Create FuturesTrader
|
||||
trader := &FuturesTrader{
|
||||
traderInstance := &FuturesTrader{
|
||||
client: client,
|
||||
cacheDuration: 0, // disable cache for testing
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &BinanceFuturesTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -298,7 +300,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
|
||||
|
||||
// TestFuturesTrader_InterfaceCompliance tests interface compliance
|
||||
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*FuturesTrader)(nil)
|
||||
var _ types.Trader = (*FuturesTrader)(nil)
|
||||
}
|
||||
|
||||
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
|
||||
@@ -343,20 +345,20 @@ func TestNewFuturesTrader(t *testing.T) {
|
||||
defer mockServer.Close()
|
||||
|
||||
// Test successful creation
|
||||
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||
t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||
|
||||
// Modify client to use mock server
|
||||
trader.client.BaseURL = mockServer.URL
|
||||
trader.client.HTTPClient = mockServer.Client()
|
||||
t1.client.BaseURL = mockServer.URL
|
||||
t1.client.HTTPClient = mockServer.Client()
|
||||
|
||||
assert.NotNil(t, trader)
|
||||
assert.NotNil(t, trader.client)
|
||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
||||
assert.NotNil(t, t1)
|
||||
assert.NotNil(t, t1.client)
|
||||
assert.Equal(t, 15*time.Second, t1.cacheDuration)
|
||||
}
|
||||
|
||||
// TestCalculatePositionSize tests position size calculation
|
||||
func TestCalculatePositionSize(t *testing.T) {
|
||||
trader := &FuturesTrader{}
|
||||
ft := &FuturesTrader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -394,7 +396,7 @@ func TestCalculatePositionSize(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||
quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"nofx/trader/types"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -126,11 +127,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 []TradeRecord
|
||||
var allTrades []types.TradeRecord
|
||||
var failedSymbols []string
|
||||
apiCalls := 0
|
||||
for _, symbol := range changedSymbols {
|
||||
var trades []TradeRecord
|
||||
var trades []types.TradeRecord
|
||||
var queryErr error
|
||||
|
||||
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"nofx/store"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package binance
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package bitget
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -48,52 +48,82 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
return nil, fmt.Errorf("failed to get fill history: %w", err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
FillList []struct {
|
||||
|
||||
// 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
|
||||
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"`
|
||||
TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close
|
||||
FeeDetail []struct {
|
||||
FeeCoin string `json:"feeCoin"`
|
||||
TotalFee string `json:"totalFee"`
|
||||
} `json:"feeDetail"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
// Try parsing as wrapped response first (fillList field)
|
||||
var wrappedResp struct {
|
||||
FillList []BitgetFill `json:"fillList"`
|
||||
}
|
||||
|
||||
// Try direct array format (Bitget V2 API returns data as direct array)
|
||||
var directFills []BitgetFill
|
||||
|
||||
// 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(resp.FillList))
|
||||
trades := make([]BitgetTrade, 0, len(directFills))
|
||||
|
||||
for _, fill := range resp.FillList {
|
||||
for _, fill := range directFills {
|
||||
fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
|
||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
||||
profit, _ := strconv.ParseFloat(fill.Profit, 64)
|
||||
cTime, _ := strconv.ParseInt(fill.CTime, 10, 64)
|
||||
|
||||
// Extract fee from feeDetail array (Bitget V2 API)
|
||||
var fee float64
|
||||
var feeAsset string
|
||||
if len(fill.FeeDetail) > 0 {
|
||||
fee, _ = strconv.ParseFloat(fill.FeeDetail[0].TotalFee, 64)
|
||||
feeAsset = fill.FeeDetail[0].FeeCoin
|
||||
}
|
||||
|
||||
// Determine order action based on side and tradeSide
|
||||
// Bitget one-way mode:
|
||||
// - buy + open = open long
|
||||
// - sell + open = open short
|
||||
// - sell + close = close long
|
||||
// - buy + close = close short
|
||||
// Bitget one-way mode: buy_single (open long), sell_single (close long)
|
||||
// Bitget hedge mode: open + buy = open_long, close + sell = close_long
|
||||
orderAction := "open_long"
|
||||
side := strings.ToLower(fill.Side)
|
||||
tradeSide := strings.ToLower(fill.TradeSide)
|
||||
|
||||
if tradeSide == "open" {
|
||||
// One-way position mode (buy_single/sell_single)
|
||||
if tradeSide == "buy_single" {
|
||||
orderAction = "open_long"
|
||||
} else if tradeSide == "sell_single" {
|
||||
orderAction = "close_long"
|
||||
} else if tradeSide == "open" {
|
||||
// Hedge mode: open
|
||||
if side == "buy" {
|
||||
orderAction = "open_long"
|
||||
} else {
|
||||
orderAction = "open_short"
|
||||
}
|
||||
} else if tradeSide == "close" {
|
||||
// Hedge mode: close
|
||||
if side == "sell" {
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
@@ -108,8 +138,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
Side: fill.Side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: -fee, // Bitget returns negative fee
|
||||
FeeAsset: fill.FeeCcy,
|
||||
Fee: -fee, // Bitget returns negative fee, convert to positive
|
||||
FeeAsset: feeAsset,
|
||||
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||
ProfitLoss: profit,
|
||||
OrderType: "MARKET",
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package bitget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// Bitget API endpoints (V2)
|
||||
@@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records
|
||||
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
@@ -1051,9 +1052,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
records := make([]ClosedPnLRecord, 0, len(resp.List))
|
||||
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
|
||||
for _, pos := range resp.List {
|
||||
record := ClosedPnLRecord{
|
||||
record := types.ClosedPnLRecord{
|
||||
Symbol: pos.Symbol,
|
||||
Side: pos.HoldSide,
|
||||
}
|
||||
@@ -1098,9 +1099,9 @@ func genBitgetClientOid() string {
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
var result []types.OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
@@ -1135,7 +1136,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
@@ -1151,9 +1152,10 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
}
|
||||
|
||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
||||
// Bitget V2 API requires planType parameter: profit_loss for SL/TP orders
|
||||
planParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"planType": "profit_loss",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
@@ -1167,29 +1169,49 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
|
||||
PlanType string `json:"planType"` // pos_loss, pos_profit
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
|
||||
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
PlanStatus string `json:"planStatus"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||
for _, order := range planOrders.EntrustedList {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
// Filter by symbol if specified
|
||||
if symbol != "" && order.Symbol != symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine trigger price based on plan type
|
||||
var triggerPrice float64
|
||||
orderType := "STOP_MARKET"
|
||||
|
||||
if order.PlanType == "pos_profit" {
|
||||
// Take profit order
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
if order.StopSurplusTriggerPrice != "" {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.StopSurplusTriggerPrice, 64)
|
||||
} else {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
}
|
||||
} else {
|
||||
// Stop loss order (pos_loss)
|
||||
if order.StopLossTriggerPrice != "" {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.StopLossTriggerPrice, 64)
|
||||
} else {
|
||||
triggerPrice, _ = strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
}
|
||||
}
|
||||
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
// Map Bitget plan type to order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.PlanType == "profit_plan" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Symbol: order.Symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
@@ -1208,7 +1230,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
@@ -1264,7 +1286,7 @@ func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResul
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package bybit
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package bybit
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
bybit "github.com/bybit-exchange/bybit.go.api"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||
@@ -900,13 +901,13 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
|
||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
|
||||
return t.getClosedPnLViaHTTP(startTime, limit)
|
||||
}
|
||||
|
||||
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
|
||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// Build query string
|
||||
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
||||
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
|
||||
@@ -967,14 +968,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]Clo
|
||||
}
|
||||
|
||||
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
|
||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
|
||||
data, ok := resultData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid result format")
|
||||
}
|
||||
|
||||
list, _ := data["list"].([]interface{})
|
||||
var records []ClosedPnLRecord
|
||||
var records []types.ClosedPnLRecord
|
||||
|
||||
for _, item := range list {
|
||||
pnl, ok := item.(map[string]interface{})
|
||||
@@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
normalizedSide = "short"
|
||||
}
|
||||
|
||||
record := ClosedPnLRecord{
|
||||
record := types.ClosedPnLRecord{
|
||||
Symbol: symbol,
|
||||
Side: normalizedSide,
|
||||
EntryPrice: avgEntryPrice,
|
||||
@@ -1046,8 +1047,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
var result []types.OpenOrder
|
||||
|
||||
// Get conditional orders (stop-loss, take-profit)
|
||||
params := map[string]interface{}{
|
||||
@@ -1088,7 +1089,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
displayType = stopOrderType
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: orderId,
|
||||
Symbol: sym,
|
||||
Side: side,
|
||||
@@ -1108,7 +1109,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
@@ -1169,7 +1170,7 @@ func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package bybit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -18,7 +20,7 @@ import (
|
||||
// BybitTraderTestSuite Bybit trader test suite
|
||||
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
||||
type BybitTraderTestSuite struct {
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
@@ -66,10 +68,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
|
||||
}))
|
||||
|
||||
// Create real Bybit trader (for interface compliance testing)
|
||||
trader := NewBybitTrader("test_api_key", "test_secret_key")
|
||||
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
|
||||
|
||||
// Create base suite
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &BybitTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -91,7 +93,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
|
||||
|
||||
// TestBybitTrader_InterfaceCompliance Test interface compliance
|
||||
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*BybitTrader)(nil)
|
||||
var _ types.Trader = (*BybitTrader)(nil)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -128,13 +130,13 @@ func TestNewBybitTrader(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, trader)
|
||||
assert.Nil(t, bt)
|
||||
} else {
|
||||
assert.NotNil(t, trader)
|
||||
assert.NotNil(t, trader.client)
|
||||
assert.NotNil(t, bt)
|
||||
assert.NotNil(t, bt.client)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -176,7 +178,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
|
||||
|
||||
// TestBybitTrader_FormatQuantity Test quantity formatting
|
||||
func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||
trader := NewBybitTrader("test", "test")
|
||||
bt := NewBybitTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -210,7 +212,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := trader.FormatQuantity(tt.symbol, tt.quantity)
|
||||
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
@@ -335,19 +337,19 @@ func convertBybitSide(side string) string {
|
||||
// TestBybitTrader_CategoryLinear Test using only linear category
|
||||
func TestBybitTrader_CategoryLinear(t *testing.T) {
|
||||
// Bybit trader should only use linear category (USDT perpetual contracts)
|
||||
trader := NewBybitTrader("test", "test")
|
||||
assert.NotNil(t, trader)
|
||||
bt := NewBybitTrader("test", "test")
|
||||
assert.NotNil(t, bt)
|
||||
|
||||
// Verify default configuration
|
||||
assert.NotNil(t, trader.client)
|
||||
assert.NotNil(t, bt.client)
|
||||
}
|
||||
|
||||
// TestBybitTrader_CacheDuration Test cache duration
|
||||
func TestBybitTrader_CacheDuration(t *testing.T) {
|
||||
trader := NewBybitTrader("test", "test")
|
||||
bt := NewBybitTrader("test", "test")
|
||||
|
||||
// Verify default cache time is 15 seconds
|
||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
||||
assert.Equal(t, 15*time.Second, bt.cacheDuration)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
304
trader/gate/order_sync.go
Normal file
304
trader/gate/order_sync.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package gate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/antihax/optional"
|
||||
"github.com/gateio/gateapi-go/v6"
|
||||
)
|
||||
|
||||
// GateTrade represents a trade record from Gate fill history
|
||||
type GateTrade struct {
|
||||
Symbol string
|
||||
TradeID string
|
||||
OrderID string
|
||||
Side string // buy or sell
|
||||
FillPrice float64
|
||||
FillQty float64 // In base currency (e.g., ETH), not contracts
|
||||
Fee float64
|
||||
FeeAsset string
|
||||
ExecTime time.Time
|
||||
ProfitLoss float64
|
||||
OrderType string
|
||||
OrderAction string // open_long, open_short, close_long, close_short
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade/fill records from Gate
|
||||
func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100 // Gate max limit
|
||||
}
|
||||
|
||||
opts := &gateapi.GetMyTradesOpts{
|
||||
Limit: optional.NewInt32(int32(limit)),
|
||||
}
|
||||
|
||||
// Get trades from Gate API
|
||||
trades, _, err := t.client.FuturesApi.GetMyTrades(t.ctx, "usdt", opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Gate", len(trades))
|
||||
|
||||
result := make([]GateTrade, 0, len(trades))
|
||||
|
||||
for _, trade := range trades {
|
||||
// Filter by start time
|
||||
createTime := int64(trade.CreateTime)
|
||||
if createTime < startTime.Unix() {
|
||||
continue
|
||||
}
|
||||
|
||||
fillPrice, err := strconv.ParseFloat(trade.Price, 64)
|
||||
if err != nil || fillPrice == 0 {
|
||||
logger.Infof("⚠️ Gate trade %d: fillPrice parse issue - raw='%s' parsed=%.8f err=%v",
|
||||
trade.Id, trade.Price, fillPrice, err)
|
||||
}
|
||||
|
||||
// Get quanto_multiplier for this contract to convert size to base currency
|
||||
quantoMultiplier := 1.0
|
||||
contract, err := t.getContract(trade.Contract)
|
||||
if err == nil && contract != nil {
|
||||
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if qm > 0 {
|
||||
quantoMultiplier = qm
|
||||
}
|
||||
}
|
||||
|
||||
// Convert contract size to actual quantity
|
||||
absSize := trade.Size
|
||||
if absSize < 0 {
|
||||
absSize = -absSize
|
||||
}
|
||||
fillQty := float64(absSize) * quantoMultiplier
|
||||
|
||||
// Determine side and order action based on size and close_size
|
||||
// Gate close_size field determines if trade is opening or closing:
|
||||
// close_size=0 && size>0: Open long
|
||||
// close_size=0 && size<0: Open short
|
||||
// close_size>0 && size>0: Close short (and possibly open long if size > close_size)
|
||||
// close_size<0 && size<0: Close long (and possibly open short if |size| > |close_size|)
|
||||
side := "BUY"
|
||||
orderAction := "open_long"
|
||||
|
||||
if trade.Size > 0 {
|
||||
side = "BUY"
|
||||
if trade.CloseSize > 0 {
|
||||
// Closing short position
|
||||
orderAction = "close_short"
|
||||
} else {
|
||||
// Opening long position
|
||||
orderAction = "open_long"
|
||||
}
|
||||
} else if trade.Size < 0 {
|
||||
side = "SELL"
|
||||
if trade.CloseSize < 0 {
|
||||
// Closing long position
|
||||
orderAction = "close_long"
|
||||
} else {
|
||||
// Opening short position
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate fee (Gate returns fee as negative value)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
if fee < 0 {
|
||||
fee = -fee
|
||||
}
|
||||
|
||||
// For closed positions, estimate PnL (Gate doesn't directly provide it in trade record)
|
||||
pnl := 0.0
|
||||
if strings.Contains(orderAction, "close") {
|
||||
// PnL would need to be calculated from position history
|
||||
// For now, we leave it as 0 and let position builder handle it
|
||||
}
|
||||
|
||||
gateTrade := GateTrade{
|
||||
Symbol: trade.Contract,
|
||||
TradeID: fmt.Sprintf("%d", trade.Id),
|
||||
OrderID: trade.OrderId,
|
||||
Side: side,
|
||||
FillPrice: fillPrice,
|
||||
FillQty: fillQty,
|
||||
Fee: fee,
|
||||
FeeAsset: "USDT",
|
||||
ExecTime: time.Unix(createTime, 0).UTC(),
|
||||
ProfitLoss: pnl,
|
||||
OrderType: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
}
|
||||
|
||||
result = append(result, gateTrade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SyncOrdersFromGate syncs Gate exchange order history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("gate")
|
||||
func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Gate trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Gate", len(trades))
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
for _, trade := range trades {
|
||||
// Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT)
|
||||
symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", ""))
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(trade.OrderAction, "short") {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
// Order exists, but still try to update position for close trades
|
||||
// This handles the case where order was created but position update failed
|
||||
if strings.HasPrefix(trade.OrderAction, "close_") && trade.FillPrice > 0 {
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Retry position update for existing trade %s failed: %v", trade.TradeID, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Gate uses one-way position mode
|
||||
Type: trade.OrderType,
|
||||
OrderAction: trade.OrderAction,
|
||||
Quantity: trade.FillQty,
|
||||
Price: trade.FillPrice,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: trade.FillQty,
|
||||
AvgFillPrice: trade.FillPrice,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record - use UTC time in milliseconds
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.OrderID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.FillPrice,
|
||||
Quantity: trade.FillQty,
|
||||
QuoteQuantity: trade.FillPrice * trade.FillQty,
|
||||
Commission: trade.Fee,
|
||||
CommissionAsset: trade.FeeAsset,
|
||||
RealizedPnL: trade.ProfitLoss,
|
||||
IsMaker: false,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
|
||||
}
|
||||
|
||||
// Create/update position record using PositionBuilder
|
||||
// Debug: Log the price being passed to ensure it's not 0
|
||||
if trade.FillPrice <= 0 {
|
||||
logger.Infof(" ⚠️ WARNING: trade %s has FillPrice=%.10f (invalid), skipping position update", trade.TradeID, trade.FillPrice)
|
||||
} else {
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f, price: %.10f)", trade.TradeID, trade.OrderAction, trade.FillQty, trade.FillPrice)
|
||||
}
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Gate order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task for Gate
|
||||
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Gate order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Gate order sync started (interval: %v)", interval)
|
||||
}
|
||||
898
trader/gate/trader.go
Normal file
898
trader/gate/trader.go
Normal file
@@ -0,0 +1,898 @@
|
||||
package gate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/antihax/optional"
|
||||
"github.com/gateio/gateapi-go/v6"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// GateTrader implements types.Trader interface for Gate.io Futures
|
||||
type GateTrader struct {
|
||||
apiKey string
|
||||
secretKey string
|
||||
client *gateapi.APIClient
|
||||
ctx context.Context
|
||||
|
||||
// Cache fields
|
||||
cachedBalance map[string]interface{}
|
||||
balanceCacheTime time.Time
|
||||
balanceCacheMutex sync.RWMutex
|
||||
cachedPositions []map[string]interface{}
|
||||
positionsCacheTime time.Time
|
||||
positionsCacheMutex sync.RWMutex
|
||||
contractsCache map[string]*gateapi.Contract
|
||||
contractsCacheMutex sync.RWMutex
|
||||
cacheDuration time.Duration
|
||||
}
|
||||
|
||||
// NewGateTrader creates a new Gate trader instance
|
||||
func NewGateTrader(apiKey, secretKey string) *GateTrader {
|
||||
config := gateapi.NewConfiguration()
|
||||
config.AddDefaultHeader("X-Gate-Channel-Id", "nofx")
|
||||
client := gateapi.NewAPIClient(config)
|
||||
|
||||
ctx := context.WithValue(context.Background(),
|
||||
gateapi.ContextGateAPIV4,
|
||||
gateapi.GateAPIV4{
|
||||
Key: apiKey,
|
||||
Secret: secretKey,
|
||||
},
|
||||
)
|
||||
|
||||
return &GateTrader{
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
client: client,
|
||||
ctx: ctx,
|
||||
contractsCache: make(map[string]*gateapi.Contract),
|
||||
cacheDuration: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// GetBalance retrieves account balance
|
||||
func (t *GateTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.balanceCacheMutex.RLock()
|
||||
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedBalance
|
||||
t.balanceCacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.balanceCacheMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
accounts, _, err := t.client.FuturesApi.ListFuturesAccounts(t.ctx, "usdt")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance: %w", err)
|
||||
}
|
||||
|
||||
total, _ := strconv.ParseFloat(accounts.Total, 64)
|
||||
available, _ := strconv.ParseFloat(accounts.Available, 64)
|
||||
unrealizedPnl, _ := strconv.ParseFloat(accounts.UnrealisedPnl, 64)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"totalWalletBalance": total,
|
||||
"availableBalance": available,
|
||||
"totalUnrealizedProfit": unrealizedPnl,
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.balanceCacheMutex.Lock()
|
||||
t.cachedBalance = result
|
||||
t.balanceCacheTime = time.Now()
|
||||
t.balanceCacheMutex.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPositions retrieves all open positions
|
||||
func (t *GateTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.positionsCacheMutex.RLock()
|
||||
if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedPositions
|
||||
t.positionsCacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.positionsCacheMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
positions, _, err := t.client.FuturesApi.ListPositions(t.ctx, "usdt", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, pos := range positions {
|
||||
if pos.Size == 0 {
|
||||
continue // Skip empty positions
|
||||
}
|
||||
|
||||
entryPrice, _ := strconv.ParseFloat(pos.EntryPrice, 64)
|
||||
markPrice, _ := strconv.ParseFloat(pos.MarkPrice, 64)
|
||||
liqPrice, _ := strconv.ParseFloat(pos.LiqPrice, 64)
|
||||
unrealizedPnl, _ := strconv.ParseFloat(pos.UnrealisedPnl, 64)
|
||||
leverage, _ := strconv.ParseFloat(pos.Leverage, 64)
|
||||
|
||||
// Gate returns position size in contracts, need to convert to base currency
|
||||
// Each contract = quanto_multiplier base currency
|
||||
contractSize := float64(pos.Size)
|
||||
if pos.Size < 0 {
|
||||
contractSize = float64(-pos.Size)
|
||||
}
|
||||
|
||||
// Get quanto_multiplier from contract info to convert contracts to actual quantity
|
||||
quantoMultiplier := 1.0
|
||||
contract, err := t.getContract(pos.Contract)
|
||||
if err == nil && contract != nil {
|
||||
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if qm > 0 {
|
||||
quantoMultiplier = qm
|
||||
}
|
||||
}
|
||||
|
||||
// Convert contract count to actual token quantity
|
||||
positionAmt := contractSize * quantoMultiplier
|
||||
|
||||
// Determine side based on position size
|
||||
side := "long"
|
||||
if pos.Size < 0 {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Contract,
|
||||
"positionAmt": positionAmt,
|
||||
"entryPrice": entryPrice,
|
||||
"markPrice": markPrice,
|
||||
"unRealizedProfit": unrealizedPnl,
|
||||
"leverage": int(leverage),
|
||||
"liquidationPrice": liqPrice,
|
||||
"side": side,
|
||||
})
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.positionsCacheMutex.Lock()
|
||||
t.cachedPositions = result
|
||||
t.positionsCacheTime = time.Now()
|
||||
t.positionsCacheMutex.Unlock()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// convertSymbol converts symbol format (e.g., BTCUSDT -> BTC_USDT)
|
||||
func (t *GateTrader) convertSymbol(symbol string) string {
|
||||
// If already in correct format
|
||||
if strings.Contains(symbol, "_") {
|
||||
return symbol
|
||||
}
|
||||
// Convert BTCUSDT to BTC_USDT
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
base := strings.TrimSuffix(symbol, "USDT")
|
||||
return base + "_USDT"
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
|
||||
// revertSymbol converts symbol back to standard format (e.g., BTC_USDT -> BTCUSDT)
|
||||
func (t *GateTrader) revertSymbol(symbol string) string {
|
||||
return strings.ReplaceAll(symbol, "_", "")
|
||||
}
|
||||
|
||||
// getContract fetches contract info with caching
|
||||
func (t *GateTrader) getContract(symbol string) (*gateapi.Contract, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// Check cache
|
||||
t.contractsCacheMutex.RLock()
|
||||
if contract, ok := t.contractsCache[symbol]; ok {
|
||||
t.contractsCacheMutex.RUnlock()
|
||||
return contract, nil
|
||||
}
|
||||
t.contractsCacheMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
contract, _, err := t.client.FuturesApi.GetFuturesContract(t.ctx, "usdt", symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get contract info: %w", err)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.contractsCacheMutex.Lock()
|
||||
t.contractsCache[symbol] = &contract
|
||||
t.contractsCacheMutex.Unlock()
|
||||
|
||||
return &contract, nil
|
||||
}
|
||||
|
||||
// SetLeverage sets the leverage for a symbol
|
||||
func (t *GateTrader) SetLeverage(symbol string, leverage int) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
_, _, err := t.client.FuturesApi.UpdatePositionLeverage(t.ctx, "usdt", symbol, fmt.Sprintf("%d", leverage), nil)
|
||||
if err != nil {
|
||||
// Gate.io may return error if leverage is already set
|
||||
if strings.Contains(err.Error(), "RISK_LIMIT_EXCEEDED") {
|
||||
logger.Warnf(" [Gate] Leverage %d exceeds limit for %s", leverage, symbol)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to set leverage: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] Leverage set to %dx for %s", leverage, symbol)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode sets margin mode (cross or isolated)
|
||||
func (t *GateTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
// Gate.io uses leverage=0 for cross margin, positive number for isolated
|
||||
// This is handled through UpdatePositionLeverage with cross_leverage_limit
|
||||
// For now, we'll skip explicit margin mode setting as it's tied to leverage
|
||||
logger.Infof(" [Gate] Margin mode is set through leverage (0=cross)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenLong opens a long position
|
||||
func (t *GateTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// Cancel old orders first
|
||||
t.CancelAllOrders(symbol)
|
||||
|
||||
// Set leverage
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
|
||||
}
|
||||
|
||||
// Get contract info for size calculation
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gate uses contract size units (each contract = quanto_multiplier base currency)
|
||||
// size = quantity / quanto_multiplier
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
order := gateapi.FuturesOrder{
|
||||
Contract: symbol,
|
||||
Size: size, // Positive for long
|
||||
Price: "0", // Market order
|
||||
Tif: "ioc",
|
||||
Text: "t-nofx",
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] OpenLong: symbol=%s, size=%d, leverage=%d", symbol, size, leverage)
|
||||
|
||||
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long position: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
t.clearCache()
|
||||
|
||||
// Parse fill price from result
|
||||
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
||||
|
||||
logger.Infof(" [Gate] Opened long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": fmt.Sprintf("%d", result.Id),
|
||||
"symbol": t.revertSymbol(symbol),
|
||||
"status": "FILLED",
|
||||
"fillPrice": fillPrice,
|
||||
"avgPrice": fillPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort opens a short position
|
||||
func (t *GateTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// Cancel old orders first
|
||||
t.CancelAllOrders(symbol)
|
||||
|
||||
// Set leverage
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
logger.Warnf(" [Gate] Failed to set leverage: %v", err)
|
||||
}
|
||||
|
||||
// Get contract info for size calculation
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gate uses contract size units
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
order := gateapi.FuturesOrder{
|
||||
Contract: symbol,
|
||||
Size: -size, // Negative for short
|
||||
Price: "0", // Market order
|
||||
Tif: "ioc",
|
||||
Text: "t-nofx",
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] OpenShort: symbol=%s, size=%d, leverage=%d", symbol, -size, leverage)
|
||||
|
||||
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short position: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
t.clearCache()
|
||||
|
||||
// Parse fill price from result
|
||||
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
||||
|
||||
logger.Infof(" [Gate] Opened short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": fmt.Sprintf("%d", result.Id),
|
||||
"symbol": t.revertSymbol(symbol),
|
||||
"status": "FILLED",
|
||||
"fillPrice": fillPrice,
|
||||
"avgPrice": fillPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseLong closes a long position
|
||||
func (t *GateTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// If quantity is 0, get current position
|
||||
if quantity == 0 {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, pos := range positions {
|
||||
posSymbol := t.convertSymbol(pos["symbol"].(string))
|
||||
if posSymbol == symbol && pos["side"] == "long" {
|
||||
quantity = pos["positionAmt"].(float64)
|
||||
break
|
||||
}
|
||||
}
|
||||
if quantity == 0 {
|
||||
return nil, fmt.Errorf("long position not found for %s", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// Get contract info for size calculation
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
// Close long = sell (use ReduceOnly, not Close which requires Size=0)
|
||||
order := gateapi.FuturesOrder{
|
||||
Contract: symbol,
|
||||
Size: -size, // Negative to close long
|
||||
Price: "0",
|
||||
Tif: "ioc",
|
||||
ReduceOnly: true,
|
||||
Text: "t-nofx-close",
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] CloseLong: symbol=%s, size=%d", symbol, -size)
|
||||
|
||||
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long position: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
t.clearCache()
|
||||
|
||||
// Parse fill price from result
|
||||
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
||||
|
||||
logger.Infof(" [Gate] Closed long position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": fmt.Sprintf("%d", result.Id),
|
||||
"symbol": t.revertSymbol(symbol),
|
||||
"status": "FILLED",
|
||||
"fillPrice": fillPrice,
|
||||
"avgPrice": fillPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort closes a short position
|
||||
func (t *GateTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// If quantity is 0, get current position
|
||||
if quantity == 0 {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, pos := range positions {
|
||||
posSymbol := t.convertSymbol(pos["symbol"].(string))
|
||||
if posSymbol == symbol && pos["side"] == "short" {
|
||||
quantity = pos["positionAmt"].(float64)
|
||||
break
|
||||
}
|
||||
}
|
||||
if quantity == 0 {
|
||||
return nil, fmt.Errorf("short position not found for %s", symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure quantity is positive
|
||||
if quantity < 0 {
|
||||
quantity = -quantity
|
||||
}
|
||||
|
||||
// Get contract info for size calculation
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
// Close short = buy (use ReduceOnly, not Close which requires Size=0)
|
||||
order := gateapi.FuturesOrder{
|
||||
Contract: symbol,
|
||||
Size: size, // Positive to close short
|
||||
Price: "0",
|
||||
Tif: "ioc",
|
||||
ReduceOnly: true,
|
||||
Text: "t-nofx-close",
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] CloseShort: symbol=%s, size=%d", symbol, size)
|
||||
|
||||
result, _, err := t.client.FuturesApi.CreateFuturesOrder(t.ctx, "usdt", order, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short position: %w", err)
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
t.clearCache()
|
||||
|
||||
// Parse fill price from result
|
||||
fillPrice, _ := strconv.ParseFloat(result.FillPrice, 64)
|
||||
|
||||
logger.Infof(" [Gate] Closed short position: orderId=%d, fillPrice=%.4f", result.Id, fillPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": fmt.Sprintf("%d", result.Id),
|
||||
"symbol": t.revertSymbol(symbol),
|
||||
"status": "FILLED",
|
||||
"fillPrice": fillPrice,
|
||||
"avgPrice": fillPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets the current market price
|
||||
func (t *GateTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
opts := &gateapi.ListFuturesTickersOpts{
|
||||
Contract: optional.NewString(symbol),
|
||||
}
|
||||
|
||||
tickers, _, err := t.client.FuturesApi.ListFuturesTickers(t.ctx, "usdt", opts)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return 0, fmt.Errorf("no ticker data for %s", symbol)
|
||||
}
|
||||
|
||||
price, _ := strconv.ParseFloat(tickers[0].Last, 64)
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// SetStopLoss sets a stop loss order
|
||||
func (t *GateTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
// For long position, stop loss means sell when price drops
|
||||
// For short position, stop loss means buy when price rises
|
||||
if strings.ToUpper(positionSide) == "LONG" {
|
||||
size = -size
|
||||
}
|
||||
|
||||
// Use price trigger order
|
||||
trigger := gateapi.FuturesPriceTriggeredOrder{
|
||||
Initial: gateapi.FuturesInitialOrder{
|
||||
Contract: symbol,
|
||||
Size: size,
|
||||
Price: "0", // Market order
|
||||
Tif: "ioc",
|
||||
ReduceOnly: true,
|
||||
Close: true,
|
||||
},
|
||||
Trigger: gateapi.FuturesPriceTrigger{
|
||||
StrategyType: 0, // Close position
|
||||
PriceType: 0, // Latest price
|
||||
Price: fmt.Sprintf("%.8f", stopPrice),
|
||||
Rule: 1, // Price <= trigger price
|
||||
},
|
||||
}
|
||||
|
||||
if strings.ToUpper(positionSide) == "SHORT" {
|
||||
trigger.Trigger.Rule = 2 // Price >= trigger price for short stop loss
|
||||
}
|
||||
|
||||
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop loss: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] Stop loss set: %s @ %.4f", symbol, stopPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTakeProfit sets a take profit order
|
||||
func (t *GateTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
size := int64(quantity / quantoMultiplier)
|
||||
if size <= 0 {
|
||||
size = 1
|
||||
}
|
||||
|
||||
// For long position, take profit means sell when price rises
|
||||
// For short position, take profit means buy when price drops
|
||||
if strings.ToUpper(positionSide) == "LONG" {
|
||||
size = -size
|
||||
}
|
||||
|
||||
trigger := gateapi.FuturesPriceTriggeredOrder{
|
||||
Initial: gateapi.FuturesInitialOrder{
|
||||
Contract: symbol,
|
||||
Size: size,
|
||||
Price: "0", // Market order
|
||||
Tif: "ioc",
|
||||
ReduceOnly: true,
|
||||
Close: true,
|
||||
},
|
||||
Trigger: gateapi.FuturesPriceTrigger{
|
||||
StrategyType: 0, // Close position
|
||||
PriceType: 0, // Latest price
|
||||
Price: fmt.Sprintf("%.8f", takeProfitPrice),
|
||||
Rule: 2, // Price >= trigger price for long take profit
|
||||
},
|
||||
}
|
||||
|
||||
if strings.ToUpper(positionSide) == "SHORT" {
|
||||
trigger.Trigger.Rule = 1 // Price <= trigger price for short take profit
|
||||
}
|
||||
|
||||
_, _, err = t.client.FuturesApi.CreatePriceTriggeredOrder(t.ctx, "usdt", trigger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take profit: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof(" [Gate] Take profit set: %s @ %.4f", symbol, takeProfitPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopLossOrders cancels stop loss orders
|
||||
func (t *GateTrader) CancelStopLossOrders(symbol string) error {
|
||||
return t.cancelTriggerOrders(symbol, "stop_loss")
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders cancels take profit orders
|
||||
func (t *GateTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
return t.cancelTriggerOrders(symbol, "take_profit")
|
||||
}
|
||||
|
||||
// cancelTriggerOrders cancels trigger orders of a specific type
|
||||
func (t *GateTrader) cancelTriggerOrders(symbol string, orderType string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
opts := &gateapi.ListPriceTriggeredOrdersOpts{
|
||||
Contract: optional.NewString(symbol),
|
||||
}
|
||||
|
||||
orders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, order := range orders {
|
||||
// Determine if it's stop loss or take profit based on trigger rule and position
|
||||
// For simplicity, cancel all matching symbol orders
|
||||
_, _, err := t.client.FuturesApi.CancelPriceTriggeredOrder(t.ctx, "usdt", fmt.Sprintf("%d", order.Id))
|
||||
if err != nil {
|
||||
logger.Warnf(" [Gate] Failed to cancel trigger order %d: %v", order.Id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders cancels all pending orders for a symbol
|
||||
func (t *GateTrader) CancelAllOrders(symbol string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
// Cancel regular orders
|
||||
_, _, err := t.client.FuturesApi.CancelFuturesOrders(t.ctx, "usdt", symbol, nil)
|
||||
if err != nil {
|
||||
// Ignore if no orders to cancel
|
||||
if !strings.Contains(err.Error(), "ORDER_NOT_FOUND") {
|
||||
logger.Warnf(" [Gate] Error canceling orders: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel trigger orders
|
||||
t.cancelTriggerOrders(symbol, "")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders cancels all stop orders (stop loss and take profit)
|
||||
func (t *GateTrader) CancelStopOrders(symbol string) error {
|
||||
t.CancelStopLossOrders(symbol)
|
||||
t.CancelTakeProfitOrders(symbol)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity formats quantity to correct precision
|
||||
func (t *GateTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
contract, err := t.getContract(symbol)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// Gate uses quanto_multiplier for contract size
|
||||
quantoMultiplier, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if quantoMultiplier > 0 {
|
||||
// Calculate number of contracts
|
||||
numContracts := quantity / quantoMultiplier
|
||||
return fmt.Sprintf("%.0f", math.Floor(numContracts)), nil
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// GetOrderStatus gets the status of an order
|
||||
func (t *GateTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
order, _, err := t.client.FuturesApi.GetFuturesOrder(t.ctx, "usdt", orderID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order status: %w", err)
|
||||
}
|
||||
|
||||
fillPrice, _ := strconv.ParseFloat(order.FillPrice, 64)
|
||||
tkFee, _ := strconv.ParseFloat(order.Tkfr, 64)
|
||||
mkFee, _ := strconv.ParseFloat(order.Mkfr, 64)
|
||||
totalFee := tkFee + mkFee
|
||||
|
||||
// Get quanto_multiplier to convert contracts to actual quantity
|
||||
quantoMultiplier := 1.0
|
||||
contract, contractErr := t.getContract(symbol)
|
||||
if contractErr == nil && contract != nil {
|
||||
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if qm > 0 {
|
||||
quantoMultiplier = qm
|
||||
}
|
||||
}
|
||||
|
||||
// Map status
|
||||
status := "NEW"
|
||||
switch order.Status {
|
||||
case "finished":
|
||||
if order.FinishAs == "filled" {
|
||||
status = "FILLED"
|
||||
} else if order.FinishAs == "cancelled" {
|
||||
status = "CANCELED"
|
||||
} else {
|
||||
status = "CLOSED"
|
||||
}
|
||||
case "open":
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
side := "BUY"
|
||||
if order.Size < 0 {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Convert contract count to actual token quantity
|
||||
executedQty := math.Abs(float64(order.Size-order.Left)) * quantoMultiplier
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": t.revertSymbol(symbol),
|
||||
"status": status,
|
||||
"avgPrice": fillPrice,
|
||||
"executedQty": executedQty,
|
||||
"side": side,
|
||||
"type": order.Tif,
|
||||
"time": int64(order.CreateTime * 1000),
|
||||
"updateTime": int64(order.FinishTime * 1000),
|
||||
"commission": totalFee,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL retrieves closed position PnL records
|
||||
func (t *GateTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
opts := &gateapi.ListPositionCloseOpts{
|
||||
Limit: optional.NewInt32(int32(limit)),
|
||||
From: optional.NewInt64(startTime.Unix()),
|
||||
}
|
||||
|
||||
closedPositions, _, err := t.client.FuturesApi.ListPositionClose(t.ctx, "usdt", opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get closed positions: %w", err)
|
||||
}
|
||||
|
||||
records := make([]types.ClosedPnLRecord, 0, len(closedPositions))
|
||||
for _, pos := range closedPositions {
|
||||
pnl, _ := strconv.ParseFloat(pos.Pnl, 64)
|
||||
|
||||
record := types.ClosedPnLRecord{
|
||||
Symbol: t.revertSymbol(pos.Contract),
|
||||
Side: pos.Side,
|
||||
RealizedPnL: pnl,
|
||||
ExitTime: time.Unix(int64(pos.Time), 0).UTC(),
|
||||
CloseType: "unknown",
|
||||
}
|
||||
|
||||
records = append(records, record)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets open/pending orders
|
||||
func (t *GateTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
opts := &gateapi.ListFuturesOrdersOpts{
|
||||
Contract: optional.NewString(symbol),
|
||||
}
|
||||
|
||||
orders, _, err := t.client.FuturesApi.ListFuturesOrders(t.ctx, "usdt", "open", opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
// Get quanto_multiplier to convert contracts to actual quantity
|
||||
quantoMultiplier := 1.0
|
||||
contract, err := t.getContract(symbol)
|
||||
if err == nil && contract != nil {
|
||||
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
||||
if qm > 0 {
|
||||
quantoMultiplier = qm
|
||||
}
|
||||
}
|
||||
|
||||
var result []types.OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Size < 0 {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Convert contract count to actual token quantity
|
||||
quantity := math.Abs(float64(order.Size)) * quantoMultiplier
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Id),
|
||||
Symbol: t.revertSymbol(order.Contract),
|
||||
Side: side,
|
||||
Type: "LIMIT",
|
||||
Price: price,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
// Also get trigger orders
|
||||
triggerOpts := &gateapi.ListPriceTriggeredOrdersOpts{
|
||||
Contract: optional.NewString(symbol),
|
||||
}
|
||||
|
||||
triggerOrders, _, err := t.client.FuturesApi.ListPriceTriggeredOrders(t.ctx, "usdt", "open", triggerOpts)
|
||||
if err == nil {
|
||||
for _, order := range triggerOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.Trigger.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Initial.Size < 0 {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
orderType := "STOP_MARKET"
|
||||
if order.Trigger.Rule == 2 {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Convert contract count to actual token quantity
|
||||
quantity := math.Abs(float64(order.Initial.Size)) * quantoMultiplier
|
||||
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Id),
|
||||
Symbol: t.revertSymbol(order.Initial.Contract),
|
||||
Side: side,
|
||||
Type: orderType,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// clearCache clears all caches
|
||||
func (t *GateTrader) clearCache() {
|
||||
t.balanceCacheMutex.Lock()
|
||||
t.cachedBalance = nil
|
||||
t.balanceCacheMutex.Unlock()
|
||||
|
||||
t.positionsCacheMutex.Lock()
|
||||
t.cachedPositions = nil
|
||||
t.positionsCacheMutex.Unlock()
|
||||
}
|
||||
|
||||
// Ensure GateTrader implements Trader interface
|
||||
var _ types.Trader = (*GateTrader)(nil)
|
||||
337
trader/gate/trader_test.go
Normal file
337
trader/gate/trader_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package gate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Part 1: GateTraderTestSuite - Inherits base test suite
|
||||
// ============================================================
|
||||
|
||||
// GateTraderTestSuite Gate trader test suite
|
||||
// Inherits TraderTestSuite and adds Gate-specific mock logic
|
||||
type GateTraderTestSuite struct {
|
||||
*testutil.TraderTestSuite
|
||||
mockServer *httptest.Server
|
||||
}
|
||||
|
||||
// NewGateTraderTestSuite creates Gate test suite with mock server
|
||||
func NewGateTraderTestSuite(t *testing.T) *GateTraderTestSuite {
|
||||
// Create mock HTTP server
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
var respBody interface{}
|
||||
|
||||
switch {
|
||||
// Mock GetBalance - /api/v4/futures/usdt/accounts
|
||||
case strings.Contains(path, "/futures/usdt/accounts"):
|
||||
respBody = map[string]interface{}{
|
||||
"total": "10000.00",
|
||||
"unrealised_pnl": "100.50",
|
||||
"available": "8000.00",
|
||||
"currency": "USDT",
|
||||
}
|
||||
|
||||
// Mock GetPositions - /api/v4/futures/usdt/positions
|
||||
case strings.Contains(path, "/futures/usdt/positions"):
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"contract": "BTC_USDT",
|
||||
"size": 500,
|
||||
"entry_price": "50000.00",
|
||||
"mark_price": "50500.00",
|
||||
"unrealised_pnl": "250.00",
|
||||
"liq_price": "45000.00",
|
||||
"leverage": "10",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetContract - /api/v4/futures/usdt/contracts/{contract}
|
||||
case strings.Contains(path, "/futures/usdt/contracts/"):
|
||||
respBody = map[string]interface{}{
|
||||
"name": "BTC_USDT",
|
||||
"quanto_multiplier": "0.001",
|
||||
"order_price_round": "0.1",
|
||||
}
|
||||
|
||||
// Mock ListFuturesContracts - /api/v4/futures/usdt/contracts
|
||||
case strings.Contains(path, "/futures/usdt/contracts"):
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"name": "BTC_USDT",
|
||||
"quanto_multiplier": "0.001",
|
||||
"order_price_round": "0.1",
|
||||
},
|
||||
{
|
||||
"name": "ETH_USDT",
|
||||
"quanto_multiplier": "0.01",
|
||||
"order_price_round": "0.01",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock ListFuturesTickers - /api/v4/futures/usdt/tickers
|
||||
case strings.Contains(path, "/futures/usdt/tickers"):
|
||||
contract := r.URL.Query().Get("contract")
|
||||
if contract == "" {
|
||||
contract = "BTC_USDT"
|
||||
}
|
||||
price := "50000.00"
|
||||
if contract == "ETH_USDT" {
|
||||
price = "3000.00"
|
||||
}
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"contract": contract,
|
||||
"last": price,
|
||||
},
|
||||
}
|
||||
|
||||
// Mock CreateFuturesOrder - /api/v4/futures/usdt/orders (POST)
|
||||
case strings.Contains(path, "/futures/usdt/orders") && r.Method == "POST":
|
||||
respBody = map[string]interface{}{
|
||||
"id": 123456,
|
||||
"contract": "BTC_USDT",
|
||||
"size": 100,
|
||||
"status": "finished",
|
||||
"finish_as": "filled",
|
||||
"fill_price": "50000.00",
|
||||
}
|
||||
|
||||
// Mock ListFuturesOrders - /api/v4/futures/usdt/orders
|
||||
case strings.Contains(path, "/futures/usdt/orders"):
|
||||
respBody = []map[string]interface{}{}
|
||||
|
||||
// Mock GetFuturesOrder - /api/v4/futures/usdt/orders/{order_id}
|
||||
case strings.Contains(path, "/futures/usdt/orders/"):
|
||||
respBody = map[string]interface{}{
|
||||
"id": 123456,
|
||||
"contract": "BTC_USDT",
|
||||
"size": 100,
|
||||
"status": "finished",
|
||||
"finish_as": "filled",
|
||||
"fill_price": "50000.00",
|
||||
"create_time": 1234567890.0,
|
||||
"update_time": 1234567890.0,
|
||||
"tkfr": "0.0005",
|
||||
"mkfr": "0.0002",
|
||||
}
|
||||
|
||||
// Mock UpdatePositionLeverage
|
||||
case strings.Contains(path, "/futures/usdt/positions/") && strings.Contains(path, "/leverage"):
|
||||
respBody = map[string]interface{}{
|
||||
"leverage": 10,
|
||||
}
|
||||
|
||||
// Mock ListPriceTriggeredOrders
|
||||
case strings.Contains(path, "/futures/usdt/price_orders"):
|
||||
respBody = []map[string]interface{}{}
|
||||
|
||||
// Mock ListPositionClose
|
||||
case strings.Contains(path, "/futures/usdt/position_close"):
|
||||
respBody = []map[string]interface{}{}
|
||||
|
||||
// Default: empty response
|
||||
default:
|
||||
respBody = map[string]interface{}{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
|
||||
// Create trader instance (will need to override URL in actual usage)
|
||||
traderInstance := NewGateTrader("test_api_key", "test_secret_key")
|
||||
|
||||
// Create base suite
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &GateTraderTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
mockServer: mockServer,
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cleans up resources
|
||||
func (s *GateTraderTestSuite) Cleanup() {
|
||||
if s.mockServer != nil {
|
||||
s.mockServer.Close()
|
||||
}
|
||||
s.TraderTestSuite.Cleanup()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 2: Interface compliance tests
|
||||
// ============================================================
|
||||
|
||||
// TestGateTrader_InterfaceCompliance tests interface compliance
|
||||
func TestGateTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ types.Trader = (*GateTrader)(nil)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 3: Gate-specific feature unit tests
|
||||
// ============================================================
|
||||
|
||||
// TestNewGateTrader tests creating Gate trader
|
||||
func TestNewGateTrader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
secretKey string
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "Successfully create",
|
||||
apiKey: "test_api_key",
|
||||
secretKey: "test_secret_key",
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "Empty API Key can still create",
|
||||
apiKey: "",
|
||||
secretKey: "test_secret_key",
|
||||
wantNil: false,
|
||||
},
|
||||
{
|
||||
name: "Empty Secret Key can still create",
|
||||
apiKey: "test_api_key",
|
||||
secretKey: "",
|
||||
wantNil: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gt := NewGateTrader(tt.apiKey, tt.secretKey)
|
||||
|
||||
if tt.wantNil {
|
||||
assert.Nil(t, gt)
|
||||
} else {
|
||||
assert.NotNil(t, gt)
|
||||
assert.NotNil(t, gt.client)
|
||||
assert.Equal(t, tt.apiKey, gt.apiKey)
|
||||
assert.Equal(t, tt.secretKey, gt.secretKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateTrader_SymbolConversion tests symbol format conversion
|
||||
func TestGateTrader_SymbolConversion(t *testing.T) {
|
||||
gt := NewGateTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "BTCUSDT to BTC_USDT",
|
||||
input: "BTCUSDT",
|
||||
expected: "BTC_USDT",
|
||||
},
|
||||
{
|
||||
name: "ETHUSDT to ETH_USDT",
|
||||
input: "ETHUSDT",
|
||||
expected: "ETH_USDT",
|
||||
},
|
||||
{
|
||||
name: "Already converted format",
|
||||
input: "BTC_USDT",
|
||||
expected: "BTC_USDT",
|
||||
},
|
||||
{
|
||||
name: "SOL symbol",
|
||||
input: "SOLUSDT",
|
||||
expected: "SOL_USDT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := gt.convertSymbol(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateTrader_RevertSymbol tests symbol reversion
|
||||
func TestGateTrader_RevertSymbol(t *testing.T) {
|
||||
gt := NewGateTrader("test", "test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "BTC_USDT to BTCUSDT",
|
||||
input: "BTC_USDT",
|
||||
expected: "BTCUSDT",
|
||||
},
|
||||
{
|
||||
name: "ETH_USDT to ETHUSDT",
|
||||
input: "ETH_USDT",
|
||||
expected: "ETHUSDT",
|
||||
},
|
||||
{
|
||||
name: "Already standard format",
|
||||
input: "BTCUSDT",
|
||||
expected: "BTCUSDT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := gt.revertSymbol(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGateTrader_CacheDuration tests cache duration
|
||||
func TestGateTrader_CacheDuration(t *testing.T) {
|
||||
gt := NewGateTrader("test", "test")
|
||||
|
||||
// Verify default cache time is 15 seconds
|
||||
assert.Equal(t, 15*time.Second, gt.cacheDuration)
|
||||
}
|
||||
|
||||
// TestGateTrader_ClearCache tests cache clearing
|
||||
func TestGateTrader_ClearCache(t *testing.T) {
|
||||
gt := NewGateTrader("test", "test")
|
||||
|
||||
// Set some cached data
|
||||
gt.cachedBalance = map[string]interface{}{"test": "data"}
|
||||
gt.cachedPositions = []map[string]interface{}{{"test": "data"}}
|
||||
|
||||
// Clear cache
|
||||
gt.clearCache()
|
||||
|
||||
// Verify cache is cleared
|
||||
assert.Nil(t, gt.cachedBalance)
|
||||
assert.Nil(t, gt.cachedPositions)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Part 4: Mock server integration tests
|
||||
// ============================================================
|
||||
|
||||
// TestGateTrader_MockServerResponseFormat tests mock server response format
|
||||
func TestGateTrader_MockServerResponseFormat(t *testing.T) {
|
||||
suite := NewGateTraderTestSuite(t)
|
||||
defer suite.Cleanup()
|
||||
|
||||
// Verify mock server is running
|
||||
assert.NotNil(t, suite.mockServer)
|
||||
assert.NotEmpty(t, suite.mockServer.URL)
|
||||
}
|
||||
@@ -194,3 +194,119 @@ func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 10: Grid Direction Adjustment
|
||||
// ============================================================================
|
||||
|
||||
const (
|
||||
// BreakoutActionAdjustDirection adjusts grid direction based on breakout
|
||||
BreakoutActionAdjustDirection BreakoutAction = 4
|
||||
)
|
||||
|
||||
// determineGridDirection determines the new grid direction based on box breakout
|
||||
// currentDirection: the current grid direction
|
||||
// breakoutLevel: which box level has been broken (short/mid/long)
|
||||
// direction: breakout direction ("up" or "down")
|
||||
// Returns: the new grid direction
|
||||
func determineGridDirection(box *market.BoxData, currentDirection market.GridDirection, breakoutLevel market.BreakoutLevel, direction string) market.GridDirection {
|
||||
if box == nil {
|
||||
return currentDirection
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
switch breakoutLevel {
|
||||
case market.BreakoutShort:
|
||||
// Short box breakout: bias direction
|
||||
// Still within mid box, so not a full trend yet
|
||||
if direction == "up" {
|
||||
return market.GridDirectionLongBias
|
||||
}
|
||||
return market.GridDirectionShortBias
|
||||
|
||||
case market.BreakoutMid:
|
||||
// Mid box breakout: full direction
|
||||
// More significant move, commit fully
|
||||
if direction == "up" {
|
||||
return market.GridDirectionLong
|
||||
}
|
||||
return market.GridDirectionShort
|
||||
|
||||
case market.BreakoutLong:
|
||||
// Long box breakout: handled by existing emergency logic
|
||||
// Return current direction, let existing handlers take over
|
||||
return currentDirection
|
||||
|
||||
case market.BreakoutNone:
|
||||
// No breakout - check if we should recover toward neutral
|
||||
return determineRecoveryDirection(price, box, currentDirection)
|
||||
|
||||
default:
|
||||
return currentDirection
|
||||
}
|
||||
}
|
||||
|
||||
// determineRecoveryDirection determines if grid direction should recover toward neutral
|
||||
// This implements the gradual recovery logic: long → long_bias → neutral ← short_bias ← short
|
||||
func determineRecoveryDirection(price float64, box *market.BoxData, currentDirection market.GridDirection) market.GridDirection {
|
||||
// Check if price is back inside the short box
|
||||
insideShortBox := price >= box.ShortLower && price <= box.ShortUpper
|
||||
|
||||
if !insideShortBox {
|
||||
// Still outside short box, maintain current direction
|
||||
return currentDirection
|
||||
}
|
||||
|
||||
// Price is inside short box, start recovery toward neutral
|
||||
switch currentDirection {
|
||||
case market.GridDirectionLong:
|
||||
// Full long → bias long
|
||||
return market.GridDirectionLongBias
|
||||
case market.GridDirectionLongBias:
|
||||
// Bias long → neutral
|
||||
return market.GridDirectionNeutral
|
||||
case market.GridDirectionShort:
|
||||
// Full short → bias short
|
||||
return market.GridDirectionShortBias
|
||||
case market.GridDirectionShortBias:
|
||||
// Bias short → neutral
|
||||
return market.GridDirectionNeutral
|
||||
default:
|
||||
return currentDirection
|
||||
}
|
||||
}
|
||||
|
||||
// getBreakoutActionWithDirection returns the appropriate action for a breakout level
|
||||
// when direction adjustment is enabled
|
||||
func getBreakoutActionWithDirection(level market.BreakoutLevel, enableDirectionAdjust bool) BreakoutAction {
|
||||
if !enableDirectionAdjust {
|
||||
// Fall back to original behavior
|
||||
return getBreakoutAction(level)
|
||||
}
|
||||
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
// Short box breakout with direction adjustment: adjust direction instead of reducing position
|
||||
return BreakoutActionAdjustDirection
|
||||
case market.BreakoutMid:
|
||||
// Mid box breakout with direction adjustment: adjust to full direction
|
||||
return BreakoutActionAdjustDirection
|
||||
case market.BreakoutLong:
|
||||
// Long box breakout: always trigger emergency handling
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
|
||||
// shouldRecoverDirection checks if the current grid direction should start recovering toward neutral
|
||||
func shouldRecoverDirection(box *market.BoxData, currentDirection market.GridDirection) bool {
|
||||
if box == nil || currentDirection == market.GridDirectionNeutral {
|
||||
return false
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
// Check if price is back inside the short box
|
||||
return price >= box.ShortLower && price <= box.ShortUpper
|
||||
}
|
||||
|
||||
@@ -120,3 +120,223 @@ func TestGetBreakoutAction(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Direction Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetBuySellRatio(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
direction market.GridDirection
|
||||
biasRatio float64
|
||||
wantBuy float64
|
||||
wantSell float64
|
||||
}{
|
||||
{"neutral", market.GridDirectionNeutral, 0.7, 0.5, 0.5},
|
||||
{"long", market.GridDirectionLong, 0.7, 1.0, 0.0},
|
||||
{"short", market.GridDirectionShort, 0.7, 0.0, 1.0},
|
||||
{"long_bias_default", market.GridDirectionLongBias, 0.7, 0.7, 0.3},
|
||||
{"short_bias_default", market.GridDirectionShortBias, 0.7, 0.3, 0.7},
|
||||
{"long_bias_custom", market.GridDirectionLongBias, 0.8, 0.8, 0.2},
|
||||
{"short_bias_custom", market.GridDirectionShortBias, 0.8, 0.2, 0.8},
|
||||
{"invalid_bias_uses_default", market.GridDirectionLongBias, 0, 0.7, 0.3},
|
||||
{"negative_bias_uses_default", market.GridDirectionLongBias, -1, 0.7, 0.3},
|
||||
}
|
||||
|
||||
const tolerance = 0.0001
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buy, sell := tt.direction.GetBuySellRatio(tt.biasRatio)
|
||||
buyDiff := buy - tt.wantBuy
|
||||
sellDiff := sell - tt.wantSell
|
||||
if buyDiff < -tolerance || buyDiff > tolerance || sellDiff < -tolerance || sellDiff > tolerance {
|
||||
t.Errorf("GetBuySellRatio(%v, %v) = (%v, %v), want (%v, %v)",
|
||||
tt.direction, tt.biasRatio, buy, sell, tt.wantBuy, tt.wantSell)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineGridDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
currentDirection market.GridDirection
|
||||
breakoutLevel market.BreakoutLevel
|
||||
direction string
|
||||
expected market.GridDirection
|
||||
}{
|
||||
// Short box breakouts
|
||||
{
|
||||
name: "short_breakout_up_neutral",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutShort,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLongBias,
|
||||
},
|
||||
{
|
||||
name: "short_breakout_down_neutral",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutShort,
|
||||
direction: "down",
|
||||
expected: market.GridDirectionShortBias,
|
||||
},
|
||||
// Mid box breakouts
|
||||
{
|
||||
name: "mid_breakout_up",
|
||||
currentDirection: market.GridDirectionLongBias,
|
||||
breakoutLevel: market.BreakoutMid,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLong,
|
||||
},
|
||||
{
|
||||
name: "mid_breakout_down",
|
||||
currentDirection: market.GridDirectionShortBias,
|
||||
breakoutLevel: market.BreakoutMid,
|
||||
direction: "down",
|
||||
expected: market.GridDirectionShort,
|
||||
},
|
||||
// Long box breakout - maintains current (emergency handling)
|
||||
{
|
||||
name: "long_breakout_maintains",
|
||||
currentDirection: market.GridDirectionLong,
|
||||
breakoutLevel: market.BreakoutLong,
|
||||
direction: "up",
|
||||
expected: market.GridDirectionLong,
|
||||
},
|
||||
// No breakout - tests recovery logic
|
||||
{
|
||||
name: "no_breakout_neutral_stays",
|
||||
currentDirection: market.GridDirectionNeutral,
|
||||
breakoutLevel: market.BreakoutNone,
|
||||
direction: "",
|
||||
expected: market.GridDirectionNeutral,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := determineGridDirection(box, tt.currentDirection, tt.breakoutLevel, tt.direction)
|
||||
if result != tt.expected {
|
||||
t.Errorf("determineGridDirection() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineRecoveryDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95, // Inside short box
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
currentDirection market.GridDirection
|
||||
expected market.GridDirection
|
||||
}{
|
||||
// Inside short box - should recover
|
||||
{"long_to_long_bias", 95, market.GridDirectionLong, market.GridDirectionLongBias},
|
||||
{"long_bias_to_neutral", 95, market.GridDirectionLongBias, market.GridDirectionNeutral},
|
||||
{"short_to_short_bias", 95, market.GridDirectionShort, market.GridDirectionShortBias},
|
||||
{"short_bias_to_neutral", 95, market.GridDirectionShortBias, market.GridDirectionNeutral},
|
||||
{"neutral_stays_neutral", 95, market.GridDirectionNeutral, market.GridDirectionNeutral},
|
||||
|
||||
// Outside short box - should maintain
|
||||
{"long_outside_stays", 101, market.GridDirectionLong, market.GridDirectionLong},
|
||||
{"short_outside_stays", 89, market.GridDirectionShort, market.GridDirectionShort},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := determineRecoveryDirection(tt.price, box, tt.currentDirection)
|
||||
if result != tt.expected {
|
||||
t.Errorf("determineRecoveryDirection(%v, %v) = %v, want %v",
|
||||
tt.price, tt.currentDirection, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutActionWithDirection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
level market.BreakoutLevel
|
||||
enableDirectionAdjust bool
|
||||
expected BreakoutAction
|
||||
}{
|
||||
// Direction adjustment disabled - original behavior
|
||||
{"short_disabled", market.BreakoutShort, false, BreakoutActionReducePosition},
|
||||
{"mid_disabled", market.BreakoutMid, false, BreakoutActionPauseGrid},
|
||||
{"long_disabled", market.BreakoutLong, false, BreakoutActionCloseAll},
|
||||
|
||||
// Direction adjustment enabled
|
||||
{"short_enabled", market.BreakoutShort, true, BreakoutActionAdjustDirection},
|
||||
{"mid_enabled", market.BreakoutMid, true, BreakoutActionAdjustDirection},
|
||||
{"long_enabled", market.BreakoutLong, true, BreakoutActionCloseAll}, // Long always triggers emergency
|
||||
{"none_enabled", market.BreakoutNone, true, BreakoutActionNone},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
action := getBreakoutActionWithDirection(tt.level, tt.enableDirectionAdjust)
|
||||
if action != tt.expected {
|
||||
t.Errorf("getBreakoutActionWithDirection(%v, %v) = %v, want %v",
|
||||
tt.level, tt.enableDirectionAdjust, action, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRecoverDirection(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price float64
|
||||
direction market.GridDirection
|
||||
expected bool
|
||||
}{
|
||||
{"neutral_inside_no_recovery", 95, market.GridDirectionNeutral, false},
|
||||
{"long_inside_should_recover", 95, market.GridDirectionLong, true},
|
||||
{"long_outside_no_recovery", 101, market.GridDirectionLong, false},
|
||||
{"short_inside_should_recover", 95, market.GridDirectionShort, true},
|
||||
{"short_outside_no_recovery", 89, market.GridDirectionShort, false},
|
||||
{"long_bias_inside_should_recover", 95, market.GridDirectionLongBias, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
box.CurrentPrice = tt.price
|
||||
result := shouldRecoverDirection(box, tt.direction)
|
||||
if result != tt.expected {
|
||||
t.Errorf("shouldRecoverDirection(price=%v, %v) = %v, want %v",
|
||||
tt.price, tt.direction, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"math"
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// HyperliquidTrader Hyperliquid trader
|
||||
@@ -26,6 +27,7 @@ type HyperliquidTrader struct {
|
||||
meta *hyperliquid.Meta // Cache meta information (including precision)
|
||||
metaMutex sync.RWMutex // Protect concurrent access to meta field
|
||||
isCrossMargin bool // Whether to use cross margin mode
|
||||
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
|
||||
// xyz dex support (stocks, forex, commodities)
|
||||
xyzMeta *xyzDexMeta
|
||||
xyzMetaMutex sync.RWMutex
|
||||
@@ -79,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
|
||||
}
|
||||
|
||||
// NewHyperliquidTrader creates a Hyperliquid trader
|
||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
|
||||
// unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
|
||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
|
||||
// Remove 0x prefix from private key (if present, case-insensitive)
|
||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||
|
||||
@@ -174,12 +177,17 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
|
||||
}
|
||||
}
|
||||
|
||||
if unifiedAccount {
|
||||
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
|
||||
}
|
||||
|
||||
return &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
meta: meta,
|
||||
isCrossMargin: true, // Use cross margin mode by default
|
||||
isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
|
||||
privateKey: privateKey,
|
||||
isTestnet: testnet,
|
||||
}, nil
|
||||
@@ -249,7 +257,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
|
||||
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
|
||||
//
|
||||
// To be compatible with auto_trader.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
// To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
// Need to return "wallet balance without unrealized PnL"
|
||||
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
|
||||
|
||||
@@ -303,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
|
||||
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
|
||||
|
||||
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
|
||||
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
|
||||
if t.isUnifiedAccount && spotUSDCBalance > 0 {
|
||||
// Add Spot balance to available balance for trading
|
||||
availableBalance = availableBalance + spotUSDCBalance
|
||||
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
|
||||
spotUSDCBalance, availableBalance)
|
||||
}
|
||||
|
||||
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
|
||||
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
|
||||
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
|
||||
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
|
||||
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
|
||||
result["spotBalance"] = spotUSDCBalance // Spot balance
|
||||
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
|
||||
@@ -1950,14 +1967,14 @@ func absFloat(x float64) float64 {
|
||||
// GetClosedPnL gets recent closing trades from Hyperliquid
|
||||
// Note: Hyperliquid does NOT have a position history API, only fill history.
|
||||
// This returns individual closing trades for real-time position closure detection.
|
||||
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []ClosedPnLRecord
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
@@ -1981,7 +1998,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, ClosedPnLRecord{
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -2001,7 +2018,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Hyperliquid
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
// Use UserFillsByTime API
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
||||
@@ -2009,7 +2026,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||
}
|
||||
|
||||
var trades []TradeRecord
|
||||
var trades []types.TradeRecord
|
||||
for _, fill := range fills {
|
||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||
@@ -2054,7 +2071,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
}
|
||||
|
||||
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
|
||||
trade := TradeRecord{
|
||||
trade := types.TradeRecord{
|
||||
TradeID: strconv.FormatInt(fill.Tid, 10),
|
||||
Symbol: fill.Coin,
|
||||
Side: side,
|
||||
@@ -2082,13 +2099,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
var result []types.OpenOrder
|
||||
for _, order := range openOrders {
|
||||
if order.Coin != symbol {
|
||||
continue
|
||||
@@ -2099,7 +2116,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
result = append(result, types.OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Oid),
|
||||
Symbol: order.Coin,
|
||||
Side: side,
|
||||
@@ -2117,7 +2134,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
@@ -2165,7 +2182,7 @@ func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrder
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &LimitOrderResult{
|
||||
return &types.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// TestMetaConcurrentAccess tests that concurrent access to meta field is safe
|
||||
func TestMetaConcurrentAccess(t *testing.T) {
|
||||
// Create a HyperliquidTrader instance with meta initialized
|
||||
trader := &HyperliquidTrader{
|
||||
ht := &HyperliquidTrader{
|
||||
ctx: context.Background(),
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
@@ -32,7 +32,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// This should not cause race conditions
|
||||
decimals := trader.getSzDecimals("BTC")
|
||||
decimals := ht.getSzDecimals("BTC")
|
||||
if decimals != 5 {
|
||||
t.Errorf("Expected decimals 5, got %d", decimals)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
|
||||
|
||||
// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field
|
||||
func TestMetaConcurrentReadWrite(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ht := &HyperliquidTrader{
|
||||
ctx: context.Background(),
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
@@ -62,7 +62,7 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
trader.getSzDecimals("BTC")
|
||||
ht.getSzDecimals("BTC")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
|
||||
go func(iteration int) {
|
||||
defer wg.Done()
|
||||
// Simulate meta update
|
||||
trader.metaMutex.Lock()
|
||||
trader.meta = &hyperliquid.Meta{
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5 + iteration%3},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
},
|
||||
}
|
||||
trader.metaMutex.Unlock()
|
||||
ht.metaMutex.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify meta is not nil after all operations
|
||||
trader.metaMutex.RLock()
|
||||
if trader.meta == nil {
|
||||
ht.metaMutex.RLock()
|
||||
if ht.meta == nil {
|
||||
t.Error("Meta should not be nil after concurrent operations")
|
||||
}
|
||||
trader.metaMutex.RUnlock()
|
||||
ht.metaMutex.RUnlock()
|
||||
}
|
||||
|
||||
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
|
||||
func TestGetSzDecimals_NilMeta(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ht := &HyperliquidTrader{
|
||||
meta: nil,
|
||||
metaMutex: sync.RWMutex{},
|
||||
}
|
||||
|
||||
// Should return default value 4 when meta is nil
|
||||
decimals := trader.getSzDecimals("BTC")
|
||||
decimals := ht.getSzDecimals("BTC")
|
||||
expectedDecimals := 4
|
||||
|
||||
if decimals != expectedDecimals {
|
||||
@@ -111,7 +111,7 @@ func TestGetSzDecimals_NilMeta(t *testing.T) {
|
||||
|
||||
// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta
|
||||
func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ht := &HyperliquidTrader{
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
@@ -133,7 +133,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.coin, func(t *testing.T) {
|
||||
decimals := trader.getSzDecimals(tt.coin)
|
||||
decimals := ht.getSzDecimals(tt.coin)
|
||||
if decimals != tt.expectedDecimals {
|
||||
t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
||||
// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues
|
||||
// Run with: go test -race -run TestMetaMutex_NoRaceCondition
|
||||
func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
||||
trader := &HyperliquidTrader{
|
||||
ht := &HyperliquidTrader{
|
||||
ctx: context.Background(),
|
||||
meta: &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
@@ -163,8 +163,8 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
trader.getSzDecimals("BTC")
|
||||
trader.getSzDecimals("ETH")
|
||||
ht.getSzDecimals("BTC")
|
||||
ht.getSzDecimals("ETH")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
trader.metaMutex.Lock()
|
||||
trader.meta = &hyperliquid.Meta{
|
||||
ht.metaMutex.Lock()
|
||||
ht.meta = &hyperliquid.Meta{
|
||||
Universe: []hyperliquid.AssetInfo{
|
||||
{Name: "BTC", SzDecimals: 5},
|
||||
{Name: "ETH", SzDecimals: 4},
|
||||
{Name: "SOL", SzDecimals: 3},
|
||||
},
|
||||
}
|
||||
trader.metaMutex.Unlock()
|
||||
ht.metaMutex.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/sonirico/go-hyperliquid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"nofx/trader/testutil"
|
||||
"nofx/trader/types"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -20,7 +22,7 @@ import (
|
||||
// HyperliquidTestSuite Hyperliquid trader test suite
|
||||
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
||||
type HyperliquidTestSuite struct {
|
||||
*TraderTestSuite // Embeds base test suite
|
||||
*testutil.TraderTestSuite // Embeds base test suite
|
||||
mockServer *httptest.Server
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
@@ -216,7 +218,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
},
|
||||
}
|
||||
|
||||
trader := &HyperliquidTrader{
|
||||
traderInstance := &HyperliquidTrader{
|
||||
exchange: exchange,
|
||||
ctx: ctx,
|
||||
walletAddr: walletAddr,
|
||||
@@ -225,7 +227,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
||||
}
|
||||
|
||||
// Create base suite
|
||||
baseSuite := NewTraderTestSuite(t, trader)
|
||||
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||
|
||||
return &HyperliquidTestSuite{
|
||||
TraderTestSuite: baseSuite,
|
||||
@@ -248,7 +250,7 @@ func (s *HyperliquidTestSuite) Cleanup() {
|
||||
|
||||
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
|
||||
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
||||
var _ Trader = (*HyperliquidTrader)(nil)
|
||||
var _ types.Trader = (*HyperliquidTrader)(nil)
|
||||
}
|
||||
|
||||
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
|
||||
@@ -562,8 +564,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trader := &HyperliquidTrader{meta: tt.meta}
|
||||
result := trader.getSzDecimals(tt.coin)
|
||||
ht := &HyperliquidTrader{meta: tt.meta}
|
||||
result := ht.getSzDecimals(tt.coin)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package trader
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
878
trader/indodax/trader.go
Normal file
878
trader/indodax/trader.go
Normal file
@@ -0,0 +1,878 @@
|
||||
package indodax
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"nofx/trader/types"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Indodax API endpoints
|
||||
const (
|
||||
indodaxBaseURL = "https://indodax.com"
|
||||
indodaxPublicAPI = "/api"
|
||||
indodaxPrivateAPI = "/tapi"
|
||||
)
|
||||
|
||||
// IndodaxTrader implements types.Trader interface for Indodax Spot Exchange
|
||||
// Indodax is Indonesia's largest crypto exchange, supporting IDR (Indonesian Rupiah) pairs.
|
||||
// Since Indodax is spot-only, futures-specific methods (OpenShort, CloseShort, leverage, etc.)
|
||||
// are gracefully stubbed.
|
||||
type IndodaxTrader struct {
|
||||
apiKey string
|
||||
secretKey string
|
||||
|
||||
httpClient *http.Client
|
||||
nonce int64
|
||||
nonceMutex sync.Mutex
|
||||
|
||||
// Cache for pair info
|
||||
pairCache map[string]*IndodaxPair
|
||||
pairCacheMutex sync.RWMutex
|
||||
pairCacheTime time.Time
|
||||
|
||||
// Cache for balance
|
||||
cachedBalance map[string]interface{}
|
||||
cachedPositions []map[string]interface{}
|
||||
balanceCacheTime time.Time
|
||||
positionCacheTime time.Time
|
||||
cacheDuration time.Duration
|
||||
cacheMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// IndodaxPair represents a trading pair on Indodax
|
||||
type IndodaxPair struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
BaseCurrency string `json:"base_currency"`
|
||||
TradedCurrency string `json:"traded_currency"`
|
||||
TradedCurrencyUnit string `json:"traded_currency_unit"`
|
||||
Description string `json:"description"`
|
||||
TickerID string `json:"ticker_id"`
|
||||
VolumePrecision int `json:"volume_precision"`
|
||||
PricePrecision float64 `json:"price_precision"`
|
||||
PriceRound int `json:"price_round"`
|
||||
Pricescale float64 `json:"pricescale"`
|
||||
TradeMinBaseCurrency float64 `json:"trade_min_base_currency"`
|
||||
TradeMinTradedCurrency float64 `json:"trade_min_traded_currency"`
|
||||
}
|
||||
|
||||
// IndodaxResponse represents the standard Indodax private API response
|
||||
type IndodaxResponse struct {
|
||||
Success int `json:"success"`
|
||||
Return json.RawMessage `json:"return,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
// IndodaxTicker represents ticker data
|
||||
type IndodaxTicker struct {
|
||||
High string `json:"high"`
|
||||
Low string `json:"low"`
|
||||
Last string `json:"last"`
|
||||
Buy string `json:"buy"`
|
||||
Sell string `json:"sell"`
|
||||
ServerTime int64 `json:"server_time"`
|
||||
}
|
||||
|
||||
// IndodaxTickerResponse wraps ticker response
|
||||
type IndodaxTickerResponse struct {
|
||||
Ticker IndodaxTicker `json:"ticker"`
|
||||
}
|
||||
|
||||
// NewIndodaxTrader creates a new Indodax trader instance
|
||||
func NewIndodaxTrader(apiKey, secretKey string) *IndodaxTrader {
|
||||
return &IndodaxTrader{
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
nonce: time.Now().UnixMilli(),
|
||||
pairCache: make(map[string]*IndodaxPair),
|
||||
cacheDuration: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// getNonce returns a unique incrementing nonce for each request
|
||||
func (t *IndodaxTrader) getNonce() int64 {
|
||||
t.nonceMutex.Lock()
|
||||
defer t.nonceMutex.Unlock()
|
||||
t.nonce++
|
||||
return t.nonce
|
||||
}
|
||||
|
||||
// sign generates HMAC-SHA512 signature for request body
|
||||
func (t *IndodaxTrader) sign(body string) string {
|
||||
mac := hmac.New(sha512.New, []byte(t.secretKey))
|
||||
mac.Write([]byte(body))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// doPublicRequest makes a public API GET request
|
||||
func (t *IndodaxTrader) doPublicRequest(path string) ([]byte, error) {
|
||||
reqURL := indodaxBaseURL + indodaxPublicAPI + path
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// doPrivateRequest makes a signed private API POST request
|
||||
func (t *IndodaxTrader) doPrivateRequest(params url.Values) ([]byte, error) {
|
||||
reqURL := indodaxBaseURL + indodaxPrivateAPI
|
||||
|
||||
// Add nonce
|
||||
params.Set("nonce", strconv.FormatInt(t.getNonce(), 10))
|
||||
|
||||
body := params.Encode()
|
||||
signature := t.sign(body)
|
||||
|
||||
req, err := http.NewRequest("POST", reqURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Key", t.apiKey)
|
||||
req.Header.Set("Sign", signature)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, fmt.Errorf("rate limit exceeded, please try again later")
|
||||
}
|
||||
|
||||
// Parse response to check success
|
||||
var apiResp IndodaxResponse
|
||||
if err := json.Unmarshal(data, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(data))
|
||||
}
|
||||
|
||||
if apiResp.Success != 1 {
|
||||
return nil, fmt.Errorf("API error: %s (code: %s)", apiResp.Error, apiResp.ErrorCode)
|
||||
}
|
||||
|
||||
return apiResp.Return, nil
|
||||
}
|
||||
|
||||
// convertSymbol converts standard symbol to Indodax format
|
||||
// e.g. BTCIDR -> btc_idr, ETHIDR -> eth_idr
|
||||
func (t *IndodaxTrader) convertSymbol(symbol string) string {
|
||||
s := strings.ToLower(symbol)
|
||||
|
||||
// Already in Indodax format (contains underscore)
|
||||
if strings.Contains(s, "_") {
|
||||
return s
|
||||
}
|
||||
|
||||
// Try to split by known base currencies
|
||||
for _, base := range []string{"idr", "btc", "usdt"} {
|
||||
if strings.HasSuffix(s, base) {
|
||||
traded := strings.TrimSuffix(s, base)
|
||||
if traded != "" {
|
||||
return traded + "_" + base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// convertSymbolBack converts Indodax format back to standard
|
||||
// e.g. btc_idr -> BTCIDR
|
||||
func (t *IndodaxTrader) convertSymbolBack(indodaxSymbol string) string {
|
||||
return strings.ToUpper(strings.ReplaceAll(indodaxSymbol, "_", ""))
|
||||
}
|
||||
|
||||
// getCoinFromSymbol extracts the traded currency from a symbol
|
||||
// e.g. btc_idr -> btc, eth_idr -> eth
|
||||
func (t *IndodaxTrader) getCoinFromSymbol(symbol string) string {
|
||||
pair := t.convertSymbol(symbol)
|
||||
parts := strings.Split(pair, "_")
|
||||
if len(parts) >= 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return strings.ToLower(symbol)
|
||||
}
|
||||
|
||||
// loadPairs loads trading pair information from the public API
|
||||
func (t *IndodaxTrader) loadPairs() error {
|
||||
t.pairCacheMutex.RLock()
|
||||
if len(t.pairCache) > 0 && time.Since(t.pairCacheTime) < 5*time.Minute {
|
||||
t.pairCacheMutex.RUnlock()
|
||||
return nil
|
||||
}
|
||||
t.pairCacheMutex.RUnlock()
|
||||
|
||||
data, err := t.doPublicRequest("/pairs")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load pairs: %w", err)
|
||||
}
|
||||
|
||||
var pairs []IndodaxPair
|
||||
if err := json.Unmarshal(data, &pairs); err != nil {
|
||||
return fmt.Errorf("failed to parse pairs: %w", err)
|
||||
}
|
||||
|
||||
t.pairCacheMutex.Lock()
|
||||
defer t.pairCacheMutex.Unlock()
|
||||
|
||||
t.pairCache = make(map[string]*IndodaxPair)
|
||||
for i := range pairs {
|
||||
p := pairs[i]
|
||||
t.pairCache[p.TickerID] = &p
|
||||
// Also index by ID (e.g. "btcidr")
|
||||
t.pairCache[p.ID] = &p
|
||||
}
|
||||
t.pairCacheTime = time.Now()
|
||||
|
||||
logger.Infof("[Indodax] Loaded %d trading pairs", len(pairs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPair gets pair info for a symbol
|
||||
func (t *IndodaxTrader) getPair(symbol string) (*IndodaxPair, error) {
|
||||
if err := t.loadPairs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pairID := t.convertSymbol(symbol)
|
||||
|
||||
t.pairCacheMutex.RLock()
|
||||
defer t.pairCacheMutex.RUnlock()
|
||||
|
||||
if pair, ok := t.pairCache[pairID]; ok {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// Try without underscore
|
||||
noUnderscore := strings.ReplaceAll(pairID, "_", "")
|
||||
if pair, ok := t.pairCache[noUnderscore]; ok {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("pair not found: %s", symbol)
|
||||
}
|
||||
|
||||
// clearCache clears cached data
|
||||
func (t *IndodaxTrader) clearCache() {
|
||||
t.cacheMutex.Lock()
|
||||
defer t.cacheMutex.Unlock()
|
||||
t.cachedBalance = nil
|
||||
t.cachedPositions = nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// types.Trader interface implementation
|
||||
// ============================================================
|
||||
|
||||
// GetBalance gets account balance from Indodax
|
||||
func (t *IndodaxTrader) GetBalance() (map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedBalance
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ServerTime int64 `json:"server_time"`
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance: %w", err)
|
||||
}
|
||||
|
||||
// Calculate total balance in IDR
|
||||
idrBalance := parseFloat(result.Balance["idr"])
|
||||
idrHold := parseFloat(result.BalanceHold["idr"])
|
||||
totalIDR := idrBalance + idrHold
|
||||
|
||||
balance := map[string]interface{}{
|
||||
"totalWalletBalance": totalIDR,
|
||||
"availableBalance": idrBalance,
|
||||
"totalUnrealizedProfit": 0.0,
|
||||
"totalEquity": totalIDR,
|
||||
"balance": totalIDR,
|
||||
"idr_balance": idrBalance,
|
||||
"idr_hold": idrHold,
|
||||
"currency": "IDR",
|
||||
"user_id": result.UserID,
|
||||
"server_time": result.ServerTime,
|
||||
}
|
||||
|
||||
// Add individual crypto balances
|
||||
for currency, amount := range result.Balance {
|
||||
if currency != "idr" {
|
||||
balance["balance_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
for currency, amount := range result.BalanceHold {
|
||||
if currency != "idr" {
|
||||
balance["hold_"+currency] = parseFloat(amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedBalance = balance
|
||||
t.balanceCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions returns currently held crypto balances as "positions"
|
||||
// Since Indodax is spot-only, each non-zero crypto balance is treated as a position
|
||||
func (t *IndodaxTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
// Check cache
|
||||
t.cacheMutex.RLock()
|
||||
if t.cachedPositions != nil && time.Since(t.positionCacheTime) < t.cacheDuration {
|
||||
cached := t.cachedPositions
|
||||
t.cacheMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.cacheMutex.RUnlock()
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getInfo")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Balance map[string]interface{} `json:"balance"`
|
||||
BalanceHold map[string]interface{} `json:"balance_hold"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions: %w", err)
|
||||
}
|
||||
|
||||
var positions []map[string]interface{}
|
||||
|
||||
for currency, amountRaw := range result.Balance {
|
||||
if currency == "idr" {
|
||||
continue
|
||||
}
|
||||
|
||||
amount := parseFloat(amountRaw)
|
||||
holdAmount := parseFloat(result.BalanceHold[currency])
|
||||
totalAmount := amount + holdAmount
|
||||
|
||||
if totalAmount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get market price for this coin
|
||||
markPrice, _ := t.GetMarketPrice(strings.ToUpper(currency) + "IDR")
|
||||
|
||||
// Calculate position value in IDR
|
||||
notionalValue := totalAmount * markPrice
|
||||
|
||||
position := map[string]interface{}{
|
||||
"symbol": strings.ToUpper(currency) + "IDR",
|
||||
"side": "LONG",
|
||||
"positionAmt": totalAmount,
|
||||
"entryPrice": markPrice, // Spot doesn't track entry price
|
||||
"markPrice": markPrice,
|
||||
"unRealizedProfit": 0.0, // Spot doesn't track unrealized PnL
|
||||
"leverage": 1.0,
|
||||
"mgnMode": "spot",
|
||||
"notionalValue": notionalValue,
|
||||
"currency": currency,
|
||||
"available": amount,
|
||||
"hold": holdAmount,
|
||||
}
|
||||
|
||||
positions = append(positions, position)
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.cacheMutex.Lock()
|
||||
t.cachedPositions = positions
|
||||
t.positionCacheTime = time.Now()
|
||||
t.cacheMutex.Unlock()
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// OpenLong opens a spot buy order
|
||||
func (t *IndodaxTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// Get market price to calculate IDR amount
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "buy")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place buy order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Buy order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "BUY",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CloseLong closes a spot position by selling
|
||||
func (t *IndodaxTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
coin := t.getCoinFromSymbol(symbol)
|
||||
|
||||
// If quantity is 0, sell all available balance
|
||||
if quantity <= 0 {
|
||||
balance, err := t.GetBalance()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get balance for close all: %w", err)
|
||||
}
|
||||
available := parseFloat(balance["balance_"+coin])
|
||||
if available <= 0 {
|
||||
return nil, fmt.Errorf("no %s balance to sell", coin)
|
||||
}
|
||||
quantity = available
|
||||
}
|
||||
|
||||
// Get market price
|
||||
price, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "trade")
|
||||
params.Set("pair", pair)
|
||||
params.Set("type", "sell")
|
||||
params.Set("price", strconv.FormatFloat(price, 'f', 0, 64))
|
||||
params.Set(coin, strconv.FormatFloat(quantity, 'f', 8, 64))
|
||||
params.Set("order_type", "limit")
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place sell order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse trade response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("[Indodax] Sell order placed: %s qty=%.8f price=%.0f", symbol, quantity, price)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": result["order_id"],
|
||||
"symbol": symbol,
|
||||
"side": "SELL",
|
||||
"price": price,
|
||||
"qty": quantity,
|
||||
"status": "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
return nil, fmt.Errorf("short selling is not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetLeverage is a no-op for Indodax (spot-only, no leverage)
|
||||
func (t *IndodaxTrader) SetLeverage(symbol string, leverage int) error {
|
||||
logger.Infof("[Indodax] SetLeverage ignored (spot-only exchange, no leverage support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode is a no-op for Indodax (spot-only, no margin)
|
||||
func (t *IndodaxTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
logger.Infof("[Indodax] SetMarginMode ignored (spot-only exchange, no margin support)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets the current market price for a symbol
|
||||
func (t *IndodaxTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
pairID := strings.ToLower(strings.ReplaceAll(t.convertSymbol(symbol), "_", ""))
|
||||
|
||||
data, err := t.doPublicRequest("/ticker/" + pairID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get ticker: %w", err)
|
||||
}
|
||||
|
||||
var tickerResp IndodaxTickerResponse
|
||||
if err := json.Unmarshal(data, &tickerResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse ticker: %w", err)
|
||||
}
|
||||
|
||||
price, err := strconv.ParseFloat(tickerResp.Ticker.Last, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price '%s': %w", tickerResp.Ticker.Last, err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// SetStopLoss is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
return fmt.Errorf("stop-loss orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// SetTakeProfit is not supported on Indodax (spot-only exchange)
|
||||
func (t *IndodaxTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
return fmt.Errorf("take-profit orders are not supported on Indodax (spot-only exchange)")
|
||||
}
|
||||
|
||||
// CancelStopLossOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelStopLossOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders is a no-op for Indodax
|
||||
func (t *IndodaxTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders cancels all open orders for a given symbol
|
||||
func (t *IndodaxTrader) CancelAllOrders(symbol string) error {
|
||||
t.clearCache()
|
||||
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
// First get open orders
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
params.Set("pair", pair)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
// Cancel each order
|
||||
for _, order := range result.Orders {
|
||||
cancelParams := url.Values{}
|
||||
cancelParams.Set("method", "cancelOrder")
|
||||
cancelParams.Set("pair", pair)
|
||||
cancelParams.Set("order_id", order.OrderID.String())
|
||||
cancelParams.Set("type", order.Type)
|
||||
|
||||
if _, err := t.doPrivateRequest(cancelParams); err != nil {
|
||||
logger.Warnf("[Indodax] Failed to cancel order %s: %v", order.OrderID, err)
|
||||
} else {
|
||||
logger.Infof("[Indodax] Cancelled order: %s", order.OrderID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopOrders is a no-op for Indodax (no stop orders)
|
||||
func (t *IndodaxTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity formats quantity to correct precision for Indodax
|
||||
func (t *IndodaxTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
pair, err := t.getPair(symbol)
|
||||
if err != nil {
|
||||
// Default: 8 decimal places
|
||||
return strconv.FormatFloat(quantity, 'f', 8, 64), nil
|
||||
}
|
||||
|
||||
precision := pair.PriceRound
|
||||
if precision <= 0 {
|
||||
precision = 8
|
||||
}
|
||||
|
||||
// Round down to avoid exceeding balance
|
||||
factor := math.Pow(10, float64(precision))
|
||||
rounded := math.Floor(quantity*factor) / factor
|
||||
|
||||
return strconv.FormatFloat(rounded, 'f', precision, 64), nil
|
||||
}
|
||||
|
||||
// GetOrderStatus gets the status of a specific order
|
||||
func (t *IndodaxTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "getOrder")
|
||||
params.Set("pair", pair)
|
||||
params.Set("order_id", orderID)
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get order status: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Order struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"order"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order: %w", err)
|
||||
}
|
||||
|
||||
// Map Indodax status to standard status
|
||||
status := "NEW"
|
||||
switch result.Order.Status {
|
||||
case "filled":
|
||||
status = "FILLED"
|
||||
case "cancelled":
|
||||
status = "CANCELED"
|
||||
case "open":
|
||||
status = "NEW"
|
||||
}
|
||||
|
||||
price, _ := strconv.ParseFloat(result.Order.Price, 64)
|
||||
|
||||
return map[string]interface{}{
|
||||
"status": status,
|
||||
"avgPrice": price,
|
||||
"executedQty": 0.0, // Indodax doesn't return executed qty in getOrder
|
||||
"commission": 0.0,
|
||||
"orderId": result.Order.OrderID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records (trade history)
|
||||
func (t *IndodaxTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||
// Indodax trade history is limited to 7 days range
|
||||
params := url.Values{}
|
||||
params.Set("method", "tradeHistory")
|
||||
params.Set("pair", "btc_idr") // Default pair; Indodax requires a pair
|
||||
if limit > 0 {
|
||||
params.Set("count", strconv.Itoa(limit))
|
||||
}
|
||||
if !startTime.IsZero() {
|
||||
params.Set("since", strconv.FormatInt(startTime.Unix(), 10))
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Trades []struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
OrderID string `json:"order_id"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
Fee string `json:"fee"`
|
||||
TradeTime string `json:"trade_time"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
} `json:"trades"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
// Trade history might return empty, that's fine
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var records []types.ClosedPnLRecord
|
||||
for _, trade := range result.Trades {
|
||||
price, _ := strconv.ParseFloat(trade.Price, 64)
|
||||
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
||||
tradeTime, _ := strconv.ParseInt(trade.TradeTime, 10, 64)
|
||||
|
||||
side := "long"
|
||||
if trade.Type == "sell" {
|
||||
side = "long" // Selling from a spot position is closing long
|
||||
}
|
||||
|
||||
records = append(records, types.ClosedPnLRecord{
|
||||
Symbol: "BTCIDR",
|
||||
Side: side,
|
||||
ExitPrice: price,
|
||||
Fee: fee,
|
||||
ExitTime: time.Unix(tradeTime, 0),
|
||||
OrderID: trade.OrderID,
|
||||
CloseType: "manual",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets open/pending orders
|
||||
func (t *IndodaxTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||
pair := t.convertSymbol(symbol)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("method", "openOrders")
|
||||
if pair != "" {
|
||||
params.Set("pair", pair)
|
||||
}
|
||||
|
||||
data, err := t.doPrivateRequest(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Orders []struct {
|
||||
OrderID json.Number `json:"order_id"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
Price string `json:"price"`
|
||||
Type string `json:"type"`
|
||||
OrderType string `json:"order_type"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []types.OpenOrder
|
||||
for _, order := range result.Orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
|
||||
side := "BUY"
|
||||
if order.Type == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
orders = append(orders, types.OpenOrder{
|
||||
OrderID: order.OrderID.String(),
|
||||
Symbol: t.convertSymbolBack(pair),
|
||||
Side: side,
|
||||
PositionSide: "LONG",
|
||||
Type: "LIMIT",
|
||||
Price: price,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper functions
|
||||
// ============================================================
|
||||
|
||||
// parseFloat safely parses a float from interface{}
|
||||
func parseFloat(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(val, 64)
|
||||
return f
|
||||
case json.Number:
|
||||
f, _ := val.Float64()
|
||||
return f
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user