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
|
# Optional: External Services
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|
||||||
# Telegram notifications (optional)
|
|
||||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
|
||||||
# TELEGRAM_CHAT_ID=your-chat-id
|
|
||||||
|
|
||||||
DB_TYPE=postgres
|
DB_TYPE=postgres
|
||||||
DB_HOST=10.
|
DB_HOST=10.
|
||||||
DB_PORT=5432
|
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**
|
- Problem:
|
||||||
>
|
- What changed:
|
||||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
- What did NOT change (scope boundary):
|
||||||
>
|
|
||||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
|
||||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
|
||||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
|
||||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
|
||||||
>
|
|
||||||
> **How to use?**
|
|
||||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
|
||||||
> - Or simply copy and paste the content from the corresponding template
|
|
||||||
|
|
||||||
---
|
## Change Type
|
||||||
|
|
||||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
- [ ] Bug fix
|
||||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
- [ ] 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 -->
|
## Linked Issues
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Type of Change
|
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix
|
|
||||||
- [ ] ✨ New feature
|
|
||||||
- [ ] 💥 Breaking change
|
|
||||||
- [ ] 📝 Documentation update
|
|
||||||
- [ ] 🎨 Code style update
|
|
||||||
- [ ] ♻️ Refactoring
|
|
||||||
- [ ] ⚡ Performance improvement
|
|
||||||
- [ ] ✅ Test update
|
|
||||||
- [ ] 🔧 Build/config change
|
|
||||||
- [ ] 🔒 Security fix
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Related Issues
|
|
||||||
|
|
||||||
- Closes #
|
- 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
|
## Compatibility
|
||||||
- [ ] Tests pass
|
|
||||||
- [ ] Verified no existing functionality broke
|
|
||||||
|
|
||||||
---
|
- Backward compatible? (`Yes/No`)
|
||||||
|
- Config/env changes? (`Yes/No`)
|
||||||
## ✅ Checklist
|
- Migration needed? (`Yes/No`)
|
||||||
|
- If yes, upgrade steps:
|
||||||
### 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!**
|
|
||||||
|
|||||||
210
.github/workflows/pr-template-suggester.yml
vendored
210
.github/workflows/pr-template-suggester.yml
vendored
@@ -1,22 +1,18 @@
|
|||||||
name: PR Template Suggester
|
name: PR Labeler
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, edited, synchronize]
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
issues: write
|
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
suggest-template:
|
label-pr:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Analyze PR and apply labels
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Analyze PR files and auto-apply template
|
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -25,166 +21,72 @@ jobs:
|
|||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: context.issue.number,
|
pull_number: context.issue.number,
|
||||||
|
per_page: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||||
|
let additions = 0, deletions = 0;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const filename = file.filename.toLowerCase();
|
const name = file.filename.toLowerCase();
|
||||||
if (filename.endsWith('.go')) goFiles++;
|
additions += file.additions || 0;
|
||||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
deletions += file.deletions || 0;
|
||||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
if (name.endsWith('.go')) goFiles++;
|
||||||
else if (filename.endsWith('.md')) mdFiles++;
|
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++;
|
else otherFiles++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + 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) {
|
// --- Size label (like OpenClaw) ---
|
||||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
const totalChanged = additions + deletions;
|
||||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
let sizeLabel = 'size: XL';
|
||||||
} else if (mdFiles / totalFiles > 0.7) {
|
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||||
|
else if (totalChanged < 500) sizeLabel = 'size: M';
|
||||||
|
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
||||||
|
labels.push(sizeLabel);
|
||||||
|
|
||||||
|
// Ensure size labels exist
|
||||||
|
for (const sl of sizeLabels) {
|
||||||
|
try {
|
||||||
|
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
// Remove stale size labels
|
||||||
|
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
for (const cl of currentLabels) {
|
||||||
|
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
|
||||||
|
await github.rest.issues.removeLabel({
|
||||||
|
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply labels
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
|
labels: labels,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prBody = pr.body || '';
|
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
||||||
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
|
|
||||||
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
|
|
||||||
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
|
|
||||||
const usesGeneralTemplate = prBody.includes('Pull Request - General');
|
|
||||||
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
|
|
||||||
|
|
||||||
if (templateLabel) {
|
|
||||||
try {
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
labels: [templateLabel]
|
|
||||||
});
|
|
||||||
console.log('Added label: ' + templateLabel);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Label might not exist, skipping...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPRBodyEmpty(body) {
|
|
||||||
if (!body || body.trim().length < 100) return true;
|
|
||||||
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
|
|
||||||
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
|
|
||||||
if (hasEmptyDescription || hasEmptyChanges) return true;
|
|
||||||
const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
|
|
||||||
if (!descMatch || descMatch[1].trim().length < 10) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (suggestedTemplate && usingDefaultTemplate) {
|
|
||||||
const shouldAutoApply = isPRBodyEmpty(prBody);
|
|
||||||
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
|
||||||
|
|
||||||
if (shouldAutoApply) {
|
|
||||||
try {
|
|
||||||
const { data: templateFile } = await github.rest.repos.getContent({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
path: templatePath,
|
|
||||||
ref: context.payload.pull_request.head.ref
|
|
||||||
});
|
|
||||||
|
|
||||||
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
|
||||||
|
|
||||||
await github.rest.pulls.update({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
pull_number: context.issue.number,
|
|
||||||
body: templateContent
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
|
||||||
|
|
||||||
let fileStats = [];
|
|
||||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
|
||||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
|
||||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
|
||||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
|
||||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
|
||||||
const fileStatsText = fileStats.join('\n');
|
|
||||||
|
|
||||||
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
|
||||||
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
|
||||||
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
|
||||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
|
||||||
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
|
||||||
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
|
||||||
'✨ You can now fill in the relevant information in the PR description!';
|
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: notifyComment
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to fetch or apply template: ' + error.message);
|
|
||||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
|
||||||
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
|
||||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
|
||||||
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
|
||||||
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: fallbackComment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('PR body has content, sending suggestion only');
|
|
||||||
|
|
||||||
let fileStats = [];
|
|
||||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
|
||||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
|
||||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
|
||||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
|
||||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
|
||||||
const fileStatsText = fileStats.join('\n');
|
|
||||||
|
|
||||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
|
||||||
|
|
||||||
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
|
||||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
|
||||||
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
|
||||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
|
||||||
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
|
||||||
'**如何使用 | How to use**\n' +
|
|
||||||
'1. 编辑PR描述 | Edit PR description\n' +
|
|
||||||
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
|
||||||
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
|
||||||
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
|
||||||
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
|
||||||
'_This is an automated suggestion. You may continue using the current template._';
|
|
||||||
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: comment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
|
||||||
console.log('PR already uses a specific template');
|
|
||||||
} else {
|
|
||||||
console.log('No specific template suggestion needed - mixed changes');
|
|
||||||
}
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
|||||||
# Go 相关
|
# Go 相关
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
|
.gocache/
|
||||||
|
|
||||||
# 操作系统
|
# 操作系统
|
||||||
.DS_Store
|
.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バトル
|
### 🏆 競争モード - リアルタイムAIバトル
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -38,7 +38,7 @@
|
|||||||
### Core Features
|
### Core Features
|
||||||
|
|
||||||
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
||||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
|
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
|
||||||
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
||||||
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
||||||
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
||||||
@@ -78,33 +78,35 @@ To use NOFX, you'll need:
|
|||||||
### CEX (Centralized Exchanges)
|
### CEX (Centralized Exchanges)
|
||||||
|
|
||||||
| Exchange | Status | Register (Fee Discount) |
|
| Exchange | Status | Register (Fee Discount) |
|
||||||
|----------|--------|-------------------------|
|
|:---------|:------:|:------------------------|
|
||||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
| <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) |
|
||||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [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/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)
|
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||||
|
|
||||||
| Exchange | Status | Register (Fee Discount) |
|
| Exchange | Status | Register (Fee Discount) |
|
||||||
|----------|--------|-------------------------|
|
|:---------|:------:|:------------------------|
|
||||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
| <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) |
|
||||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
| <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
|
## Supported AI Models
|
||||||
|
|
||||||
| AI Model | Status | Get API Key |
|
| AI Model | Status | Get API Key |
|
||||||
|----------|--------|-------------|
|
|:---------|:------:|:------------|
|
||||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.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) |
|
||||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.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) |
|
||||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
| <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"
|
provider = "google"
|
||||||
} else if strings.Contains(modelNameLower, "deepseek") {
|
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||||
provider = "deepseek"
|
provider = "deepseek"
|
||||||
|
} else if strings.Contains(modelNameLower, "minimax") {
|
||||||
|
provider = "minimax"
|
||||||
} else if model.CustomAPIURL != "" {
|
} else if model.CustomAPIURL != "" {
|
||||||
provider = "custom"
|
provider = "custom"
|
||||||
} else {
|
} 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()
|
||||||
|
}
|
||||||
765
api/server.go
765
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) {
|
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -145,9 +146,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
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 {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -155,6 +157,16 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
return
|
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
|
// Serialize configuration
|
||||||
configJSON, err := json.Marshal(req.Config)
|
configJSON, err := json.Marshal(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,7 +190,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration and collect warnings
|
// Validate configuration and collect warnings
|
||||||
warnings := validateStrategyConfig(&req.Config)
|
warnings := validateStrategyConfig(req.Config)
|
||||||
|
|
||||||
response := gin.H{
|
response := gin.H{
|
||||||
"id": strategy.ID,
|
"id": strategy.ID,
|
||||||
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
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) {
|
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
strategyID := c.Param("id")
|
strategyID := c.Param("id")
|
||||||
@@ -213,11 +228,11 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
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"`
|
IsPublic bool `json:"is_public"`
|
||||||
ConfigVisible bool `json:"config_visible"`
|
ConfigVisible bool `json:"config_visible"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -225,8 +240,33 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize configuration
|
// Start with the existing config as base — preserves all unmentioned fields.
|
||||||
configJSON, err := json.Marshal(req.Config)
|
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 {
|
if err != nil {
|
||||||
SafeInternalError(c, "Serialize configuration", err)
|
SafeInternalError(c, "Serialize configuration", err)
|
||||||
return
|
return
|
||||||
@@ -235,8 +275,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
strategy := &store.Strategy{
|
strategy := &store.Strategy{
|
||||||
ID: strategyID,
|
ID: strategyID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: req.Name,
|
Name: name,
|
||||||
Description: req.Description,
|
Description: description,
|
||||||
Config: string(configJSON),
|
Config: string(configJSON),
|
||||||
IsPublic: req.IsPublic,
|
IsPublic: req.IsPublic,
|
||||||
ConfigVisible: req.ConfigVisible,
|
ConfigVisible: req.ConfigVisible,
|
||||||
@@ -247,8 +287,8 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration and collect warnings
|
// Validate merged configuration and collect warnings
|
||||||
warnings := validateStrategyConfig(&req.Config)
|
warnings := validateStrategyConfig(&mergedConfig)
|
||||||
|
|
||||||
response := gin.H{"message": "Strategy updated successfully"}
|
response := gin.H{"message": "Strategy updated successfully"}
|
||||||
if len(warnings) > 0 {
|
if len(warnings) > 0 {
|
||||||
@@ -625,6 +665,18 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
|||||||
case "openai":
|
case "openai":
|
||||||
aiClient = mcp.NewOpenAIClient()
|
aiClient = mcp.NewOpenAIClient()
|
||||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
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:
|
default:
|
||||||
// Use generic client
|
// Use generic client
|
||||||
aiClient = mcp.NewClient()
|
aiClient = mcp.NewClient()
|
||||||
|
|||||||
35
auth/auth.go
35
auth/auth.go
@@ -1,15 +1,12 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pquerna/otp/totp"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
|
|||||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||||
const maxBlacklistEntries = 100_000
|
const maxBlacklistEntries = 100_000
|
||||||
|
|
||||||
// OTPIssuer is the OTP issuer name
|
|
||||||
const OTPIssuer = "nofxAI"
|
|
||||||
|
|
||||||
// SetJWTSecret sets the JWT secret key
|
// SetJWTSecret sets the JWT secret key
|
||||||
func SetJWTSecret(secret string) {
|
func SetJWTSecret(secret string) {
|
||||||
JWTSecret = []byte(secret)
|
JWTSecret = []byte(secret)
|
||||||
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOTPSecret generates OTP secret
|
|
||||||
func GenerateOTPSecret() (string, error) {
|
|
||||||
secret := make([]byte, 20)
|
|
||||||
_, err := rand.Read(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := totp.Generate(totp.GenerateOpts{
|
|
||||||
Issuer: OTPIssuer,
|
|
||||||
AccountName: uuid.New().String(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return key.Secret(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyOTP verifies OTP code
|
|
||||||
func VerifyOTP(secret, code string) bool {
|
|
||||||
return totp.Validate(code, secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateJWT generates JWT token
|
// GenerateJWT generates JWT token
|
||||||
func GenerateJWT(userID, email string) (string, error) {
|
func GenerateJWT(userID, email string) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
|||||||
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
return nil, fmt.Errorf("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOTPQRCodeURL gets OTP QR code URL
|
|
||||||
func GetOTPQRCodeURL(secret, email string) string {
|
|
||||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,6 +71,34 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
|||||||
oaiC := mcp.NewOpenAIClientWithOptions()
|
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||||
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||||
return oaiC, nil
|
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":
|
case "custom":
|
||||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||||
return nil, fmt.Errorf("custom provider requires base_url, api key and 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
|
cp := *c.Client
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
case *mcp.MiniMaxClient:
|
||||||
|
if c != nil && c.Client != nil {
|
||||||
|
cp := *c.Client
|
||||||
|
return &cp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fall back to a new default client
|
// Fall back to a new default client
|
||||||
return mcp.NewClient().(*mcp.Client)
|
return mcp.NewClient().(*mcp.Client)
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ var global *Config
|
|||||||
// Only contains truly global config, trading related config is at trader/strategy level
|
// Only contains truly global config, trading related config is at trader/strategy level
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Service configuration
|
// Service configuration
|
||||||
APIServerPort int
|
APIServerPort int
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
RegistrationEnabled bool
|
|
||||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
DBType string // sqlite or postgres
|
DBType string // sqlite or postgres
|
||||||
@@ -44,14 +42,13 @@ type Config struct {
|
|||||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||||
AlpacaSecretKey string // Alpaca secret key
|
AlpacaSecretKey string // Alpaca secret key
|
||||||
TwelveDataKey string // TwelveData API key for forex & metals
|
TwelveDataKey string // TwelveData API key for forex & metals
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes global configuration (from .env)
|
// Init initializes global configuration (from .env)
|
||||||
func Init() {
|
func Init() {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
APIServerPort: 8080,
|
APIServerPort: 8080,
|
||||||
RegistrationEnabled: true,
|
|
||||||
MaxUsers: 10, // Default: 10 users allowed
|
|
||||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||||
// Database defaults
|
// Database defaults
|
||||||
DBType: "sqlite",
|
DBType: "sqlite",
|
||||||
@@ -71,16 +68,6 @@ func Init() {
|
|||||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
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 v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||||
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
||||||
cfg.APIServerPort = port
|
cfg.APIServerPort = port
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
|||||||
client = mcp.NewGrokClient()
|
client = mcp.NewGrokClient()
|
||||||
case "kimi":
|
case "kimi":
|
||||||
client = mcp.NewKimiClient()
|
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:
|
default:
|
||||||
client = mcp.New()
|
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)
|
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||||
|
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
## 🔑 Prerequisites
|
||||||
|
|
||||||
Before starting, ensure you have:
|
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 を実行 - いつでもモデルを切り替え可能
|
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||||
@@ -63,6 +63,8 @@ NOFXを使用するには以下が必要です:
|
|||||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
||||||
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
| **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||||
|
| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||||
|
|
||||||
### Perp-DEX (分散型永久先物取引所)
|
### Perp-DEX (分散型永久先物取引所)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
### 핵심 기능
|
### 핵심 기능
|
||||||
|
|
||||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||||
@@ -63,6 +63,8 @@ NOFX를 사용하려면 다음이 필요합니다:
|
|||||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
||||||
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
| **KuCoin** | ✅ 지원 | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||||
|
| **Gate** | ✅ 지원 | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||||
|
|
||||||
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
### Основные функции
|
### Основные функции
|
||||||
|
|
||||||
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
||||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
|
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||||
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
||||||
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
||||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
|
||||||
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
| **KuCoin** | ✅ Поддерживается | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||||
|
| **Gate** | ✅ Поддерживается | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||||
|
|
||||||
### Perp-DEX (Децентрализованные биржи)
|
### Perp-DEX (Децентрализованные биржи)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
### Основні функції
|
### Основні функції
|
||||||
|
|
||||||
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
||||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||||
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
||||||
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
||||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||||
@@ -63,6 +63,8 @@
|
|||||||
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||||
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
| **KuCoin** | ✅ Підтримується | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||||
|
| **Gate** | ✅ Підтримується | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||||
|
|
||||||
### Perp-DEX (Децентралізовані біржі)
|
### Perp-DEX (Децентралізовані біржі)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
### Tính Năng Chính
|
### Tính Năng Chính
|
||||||
|
|
||||||
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
|
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
|
||||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||||
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
|
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
|
||||||
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
|
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
|
||||||
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
||||||
@@ -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) |
|
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
|
| **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) |
|
| **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)
|
### Perp-DEX (Sàn Phi Tập Trung)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
### 核心功能
|
### 核心功能
|
||||||
|
|
||||||
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
||||||
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
|
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||||
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
||||||
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
||||||
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
||||||
@@ -75,6 +75,8 @@
|
|||||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
||||||
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
| **KuCoin** | ✅ 已支持 | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||||
|
| **Gate** | ✅ 已支持 | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||||
|
|
||||||
### Perp-DEX (去中心化永续交易所)
|
### Perp-DEX (去中心化永续交易所)
|
||||||
|
|
||||||
|
|||||||
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/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pquerna/otp v1.4.0
|
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/sonirico/go-hyperliquid v0.26.0
|
github.com/sonirico/go-hyperliquid v0.26.0
|
||||||
@@ -22,10 +21,14 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/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/armon/go-radix v1.0.0 // indirect
|
||||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||||
|
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // 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/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
@@ -60,15 +68,20 @@ require (
|
|||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/jpillora/backoff v1.0.0 // indirect
|
github.com/jpillora/backoff v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // 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/mailru/easyjson v0.9.1 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // 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/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
@@ -80,6 +93,7 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/sonirico/vago v0.10.0 // indirect
|
github.com/sonirico/vago v0.10.0 // indirect
|
||||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // 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/supranational/blst v0.3.16 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // 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/module/apmzerolog/v2 v2.7.1 // indirect
|
||||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||||
go.elastic.co/fastjson v1.5.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/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/arch v0.20.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/mod v0.27.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.36.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/text v0.29.0 // indirect
|
||||||
|
golang.org/x/time v0.9.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 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/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=
|
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/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||||
|
github.com/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 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
|
github.com/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 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
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 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
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 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
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 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
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=
|
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-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 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
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 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
@@ -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-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 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
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 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
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/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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
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 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
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=
|
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/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 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
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 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
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=
|
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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
|
||||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
|
||||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
@@ -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/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 h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
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 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
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 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 h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
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.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.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.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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
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/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 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
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/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 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
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 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
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 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
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 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
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-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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
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 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/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 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
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-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 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
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 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.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 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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.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 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
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-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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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 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.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
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.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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
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
|
package kernel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
|
"nofx/provider/hyperliquid"
|
||||||
"nofx/provider/nofxos"
|
"nofx/provider/nofxos"
|
||||||
"nofx/security"
|
"nofx/security"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
@@ -490,6 +492,44 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
// 空列表是正常情况,直接返回
|
// 空列表是正常情况,直接返回
|
||||||
return e.filterExcludedCoins(coins), nil
|
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":
|
case "mixed":
|
||||||
if coinSource.UseAI500 {
|
if coinSource.UseAI500 {
|
||||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
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 {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
if _, exists := symbolSources[symbol]; !exists {
|
if _, exists := symbolSources[symbol]; !exists {
|
||||||
@@ -640,6 +702,52 @@ func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
|||||||
return candidates, nil
|
return candidates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getHyperAllCoins returns all available Hyperliquid perpetual coins
|
||||||
|
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []CandidateCoin
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
// Add USDT suffix for compatibility
|
||||||
|
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||||
|
candidates = append(candidates, CandidateCoin{
|
||||||
|
Symbol: normalizedSymbol,
|
||||||
|
Sources: []string{"hyper_all"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
|
||||||
|
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []CandidateCoin
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
// Add USDT suffix for compatibility
|
||||||
|
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||||
|
candidates = append(candidates, CandidateCoin{
|
||||||
|
Symbol: normalizedSymbol,
|
||||||
|
Sources: []string{"hyper_main"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// External & Quant Data
|
// External & Quant Data
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1350,6 +1458,8 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
|||||||
hasAI500 := false
|
hasAI500 := false
|
||||||
hasOITop := false
|
hasOITop := false
|
||||||
hasOILow := false
|
hasOILow := false
|
||||||
|
hasHyperAll := false
|
||||||
|
hasHyperMain := false
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
switch s {
|
switch s {
|
||||||
case "ai500":
|
case "ai500":
|
||||||
@@ -1358,6 +1468,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
|||||||
hasOITop = true
|
hasOITop = true
|
||||||
case "oi_low":
|
case "oi_low":
|
||||||
hasOILow = true
|
hasOILow = true
|
||||||
|
case "hyper_all":
|
||||||
|
hasHyperAll = true
|
||||||
|
case "hyper_main":
|
||||||
|
hasHyperMain = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hasAI500 && hasOITop {
|
if hasAI500 && hasOITop {
|
||||||
@@ -1369,6 +1483,12 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
|||||||
if hasOITop && hasOILow {
|
if hasOITop && hasOILow {
|
||||||
return " (OI_Top+OI_Low)"
|
return " (OI_Top+OI_Low)"
|
||||||
}
|
}
|
||||||
|
if hasHyperMain && hasAI500 {
|
||||||
|
return " (HyperMain+AI500)"
|
||||||
|
}
|
||||||
|
if hasHyperAll || hasHyperMain {
|
||||||
|
return " (Hyperliquid)"
|
||||||
|
}
|
||||||
return " (Multiple sources)"
|
return " (Multiple sources)"
|
||||||
} else if len(sources) == 1 {
|
} else if len(sources) == 1 {
|
||||||
switch sources[0] {
|
switch sources[0] {
|
||||||
@@ -1380,6 +1500,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
|||||||
return " (OI_Low 持仓减少)"
|
return " (OI_Low 持仓减少)"
|
||||||
case "static":
|
case "static":
|
||||||
return " (Manual selection)"
|
return " (Manual selection)"
|
||||||
|
case "hyper_all":
|
||||||
|
return " (Hyperliquid All)"
|
||||||
|
case "hyper_main":
|
||||||
|
return " (Hyperliquid Top20)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ type GridContext struct {
|
|||||||
|
|
||||||
// Box indicators (Donchian Channels)
|
// Box indicators (Donchian Channels)
|
||||||
BoxData *market.BoxData `json:"box_data,omitempty"`
|
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.ActiveOrderCount))
|
||||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
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")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Grid levels detail
|
// 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("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
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")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Grid levels detail
|
// Grid levels detail
|
||||||
|
|||||||
10
main.go
10
main.go
@@ -11,6 +11,7 @@ import (
|
|||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/telegram"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -130,12 +131,21 @@ func main() {
|
|||||||
|
|
||||||
// Start API server
|
// Start API server
|
||||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
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() {
|
go func() {
|
||||||
if err := server.Start(); err != nil {
|
if err := server.Start(); err != nil {
|
||||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
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
|
// Wait for interrupt signal
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|||||||
@@ -407,7 +407,6 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// RemoveTrader removes a trader from memory (does not affect database)
|
// RemoveTrader removes a trader from memory (does not affect database)
|
||||||
// Used to force reload when updating trader configuration
|
// Used to force reload when updating trader configuration
|
||||||
// If the trader is running, it will be stopped first
|
// If the trader is running, it will be stopped first
|
||||||
@@ -664,11 +663,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
QwenKey: "",
|
QwenKey: "",
|
||||||
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
||||||
CustomModelName: aiModelCfg.CustomModelName,
|
CustomModelName: aiModelCfg.CustomModelName,
|
||||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||||
InitialBalance: traderCfg.InitialBalance,
|
InitialBalance: traderCfg.InitialBalance,
|
||||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||||
StrategyConfig: strategyConfig,
|
StrategyConfig: strategyConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||||
@@ -690,9 +689,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
|
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
|
||||||
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
|
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
|
||||||
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
|
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
|
||||||
|
case "gate":
|
||||||
|
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
|
||||||
|
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
|
||||||
|
case "kucoin":
|
||||||
|
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
|
||||||
|
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
|
||||||
|
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
|
||||||
case "hyperliquid":
|
case "hyperliquid":
|
||||||
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
||||||
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
||||||
|
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
|
||||||
case "aster":
|
case "aster":
|
||||||
traderConfig.AsterUser = exchangeCfg.AsterUser
|
traderConfig.AsterUser = exchangeCfg.AsterUser
|
||||||
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
||||||
@@ -703,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
||||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||||
|
case "indodax":
|
||||||
|
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||||
|
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set API keys based on AI model (convert EncryptedString to string)
|
// Set API keys based on AI model (convert EncryptedString to string)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var (
|
|||||||
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
||||||
|
|
||||||
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||||
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
|
||||||
// Map interval string to coinank enum
|
// Map interval string to coinank enum
|
||||||
var coinankInterval coinank_enum.Interval
|
var coinankInterval coinank_enum.Interval
|
||||||
switch interval {
|
switch interval {
|
||||||
@@ -67,13 +67,44 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
|||||||
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map exchange string to coinank enum
|
||||||
|
var coinankExchange coinank_enum.Exchange
|
||||||
|
switch strings.ToLower(exchange) {
|
||||||
|
case "binance":
|
||||||
|
coinankExchange = coinank_enum.Binance
|
||||||
|
case "bybit":
|
||||||
|
coinankExchange = coinank_enum.Bybit
|
||||||
|
case "okx":
|
||||||
|
coinankExchange = coinank_enum.Okex
|
||||||
|
case "bitget":
|
||||||
|
coinankExchange = coinank_enum.Bitget
|
||||||
|
case "gate":
|
||||||
|
coinankExchange = coinank_enum.Gate
|
||||||
|
case "hyperliquid":
|
||||||
|
coinankExchange = coinank_enum.Hyperliquid
|
||||||
|
case "aster":
|
||||||
|
coinankExchange = coinank_enum.Aster
|
||||||
|
default:
|
||||||
|
// Default to Binance for unknown exchanges
|
||||||
|
coinankExchange = coinank_enum.Binance
|
||||||
|
}
|
||||||
|
|
||||||
// Call CoinAnk free/open API (no authentication required)
|
// Call CoinAnk free/open API (no authentication required)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ts := time.Now().UnixMilli()
|
ts := time.Now().UnixMilli()
|
||||||
// Use "To" side to search backward from current time (get historical klines)
|
// Use "To" side to search backward from current time (get historical klines)
|
||||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
// If exchange-specific data fails, fallback to Binance
|
||||||
|
if coinankExchange != coinank_enum.Binance {
|
||||||
|
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||||||
|
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert coinank kline format to market.Kline format
|
// Convert coinank kline format to market.Kline format
|
||||||
@@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
|
|||||||
return klines, nil
|
return klines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves market data for the specified token
|
// Get retrieves market data for the specified token (uses Binance data by default)
|
||||||
func Get(symbol string) (*Data, error) {
|
func Get(symbol string) (*Data, error) {
|
||||||
|
return GetWithExchange(symbol, "binance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithExchange retrieves market data for the specified token using exchange-specific data
|
||||||
|
func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||||||
var klines3m, klines4h []Kline
|
var klines3m, klines4h []Kline
|
||||||
var err error
|
var err error
|
||||||
// Normalize symbol
|
// Normalize symbol
|
||||||
@@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) {
|
|||||||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||||||
isXyzAsset := IsXyzDexAsset(symbol)
|
isXyzAsset := IsXyzDexAsset(symbol)
|
||||||
|
|
||||||
|
// For hyperliquid exchange, also use Hyperliquid API
|
||||||
|
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
|
||||||
|
|
||||||
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
||||||
if isXyzAsset {
|
if useHyperliquidAPI {
|
||||||
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||||||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use CoinAnk for regular crypto assets
|
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +205,15 @@ func Get(symbol string) (*Data, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get 4-hour K-line data
|
// Get 4-hour K-line data
|
||||||
if isXyzAsset {
|
if useHyperliquidAPI {
|
||||||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,8 +329,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use CoinAnk for regular crypto assets
|
// Use CoinAnk for regular crypto assets (default to Binance)
|
||||||
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
|
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||||
continue
|
continue
|
||||||
@@ -1068,6 +1107,11 @@ func Normalize(symbol string) string {
|
|||||||
return "xyz:" + base
|
return "xyz:" + base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||||||
|
symbol = strings.ReplaceAll(symbol, "_", "")
|
||||||
|
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
|
||||||
|
symbol = strings.ReplaceAll(symbol, "-", "")
|
||||||
|
|
||||||
// For regular crypto assets
|
// For regular crypto assets
|
||||||
if strings.HasSuffix(symbol, "USDT") {
|
if strings.HasSuffix(symbol, "USDT") {
|
||||||
return symbol
|
return symbol
|
||||||
@@ -1283,7 +1327,7 @@ func GetBoxData(symbol string) (*BoxData, error) {
|
|||||||
if IsXyzDexAsset(symbol) {
|
if IsXyzDexAsset(symbol) {
|
||||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||||
} else {
|
} else {
|
||||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
|
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -226,3 +226,37 @@ const (
|
|||||||
BreakoutMid BreakoutLevel = "mid"
|
BreakoutMid BreakoutLevel = "mid"
|
||||||
BreakoutLong BreakoutLevel = "long"
|
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
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -9,78 +25,67 @@ import (
|
|||||||
const (
|
const (
|
||||||
ProviderClaude = "claude"
|
ProviderClaude = "claude"
|
||||||
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
||||||
DefaultClaudeModel = "claude-opus-4-5-20251101"
|
DefaultClaudeModel = "claude-opus-4-6"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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 {
|
type ClaudeClient struct {
|
||||||
*Client
|
*Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaudeClient creates Claude client (backward compatible)
|
// NewClaudeClient creates a ClaudeClient with default settings.
|
||||||
func NewClaudeClient() AIClient {
|
func NewClaudeClient() AIClient {
|
||||||
return NewClaudeClientWithOptions()
|
return NewClaudeClientWithOptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
|
||||||
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
||||||
// 1. Create Claude preset options
|
baseClient := NewClient(append([]ClientOption{
|
||||||
claudeOpts := []ClientOption{
|
|
||||||
WithProvider(ProviderClaude),
|
WithProvider(ProviderClaude),
|
||||||
WithModel(DefaultClaudeModel),
|
WithModel(DefaultClaudeModel),
|
||||||
WithBaseURL(DefaultClaudeBaseURL),
|
WithBaseURL(DefaultClaudeBaseURL),
|
||||||
}
|
}, opts...)...).(*Client)
|
||||||
|
|
||||||
// 2. Merge user options (user options have higher priority)
|
c := &ClaudeClient{Client: baseClient}
|
||||||
allOpts := append(claudeOpts, opts...)
|
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
|
||||||
|
return c
|
||||||
// 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) {
|
// ── Hook overrides ────────────────────────────────────────────────────────────
|
||||||
c.APIKey = apiKey
|
|
||||||
|
|
||||||
|
// SetAPIKey stores credentials and optional custom endpoint / model.
|
||||||
|
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
|
||||||
|
c.APIKey = apiKey
|
||||||
if len(apiKey) > 8 {
|
if len(apiKey) > 8 {
|
||||||
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||||
}
|
}
|
||||||
if customURL != "" {
|
if customURL != "" {
|
||||||
c.BaseURL = customURL
|
c.BaseURL = customURL
|
||||||
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
|
||||||
} else {
|
|
||||||
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
|
||||||
}
|
}
|
||||||
if customModel != "" {
|
if customModel != "" {
|
||||||
c.Model = customModel
|
c.Model = customModel
|
||||||
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
|
||||||
} else {
|
|
||||||
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
|
||||||
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
func (c *ClaudeClient) setAuthHeader(h http.Header) {
|
||||||
reqHeaders.Set("x-api-key", c.APIKey)
|
h.Set("x-api-key", c.APIKey)
|
||||||
reqHeaders.Set("anthropic-version", "2023-06-01")
|
h.Set("anthropic-version", "2023-06-01")
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildUrl Claude uses /messages endpoint
|
// buildUrl targets /messages instead of /chat/completions.
|
||||||
func (c *ClaudeClient) buildUrl() string {
|
func (c *ClaudeClient) buildUrl() string {
|
||||||
return fmt.Sprintf("%s/messages", c.BaseURL)
|
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 {
|
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||||
requestBody := map[string]any{
|
return map[string]any{
|
||||||
"model": c.Model,
|
"model": c.Model,
|
||||||
"max_tokens": c.MaxTokens,
|
"max_tokens": c.MaxTokens,
|
||||||
"system": systemPrompt,
|
"system": systemPrompt,
|
||||||
@@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
|
|||||||
{"role": "user", "content": userPrompt},
|
{"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) {
|
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 {
|
Content []struct {
|
||||||
Type string `json:"type"`
|
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"`
|
} `json:"content"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
InputTokens int `json:"input_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
@@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
|||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &response); err != nil {
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
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 {
|
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
|
||||||
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
if TokenUsageCallback != nil && total > 0 {
|
||||||
}
|
|
||||||
|
|
||||||
if len(response.Content) == 0 {
|
|
||||||
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report token usage if callback is set
|
|
||||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
|
||||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
|
||||||
TokenUsageCallback(TokenUsage{
|
TokenUsageCallback(TokenUsage{
|
||||||
Provider: c.Provider,
|
Provider: c.Provider,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
PromptTokens: response.Usage.InputTokens,
|
PromptTokens: raw.Usage.InputTokens,
|
||||||
CompletionTokens: response.Usage.OutputTokens,
|
CompletionTokens: raw.Usage.OutputTokens,
|
||||||
TotalTokens: totalTokens,
|
TotalTokens: total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find text content
|
result := &LLMResponse{}
|
||||||
for _, content := range response.Content {
|
for _, block := range raw.Content {
|
||||||
if content.Type == "text" {
|
switch block.Type {
|
||||||
return content.Text, nil
|
case "text":
|
||||||
|
result.Content = block.Text
|
||||||
|
|
||||||
|
case "tool_use":
|
||||||
|
// Input is a JSON object; serialise back to a JSON string so it
|
||||||
|
// matches the ToolCallFunction.Arguments field (always a string).
|
||||||
|
argsJSON, err := json.Marshal(block.Input)
|
||||||
|
if err != nil {
|
||||||
|
argsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: ToolCallFunction{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: string(argsJSON),
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result, nil
|
||||||
return "", fmt.Errorf("no text content in Claude response")
|
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
242
mcp/client.go
242
mcp/client.go
@@ -1,7 +1,9 @@
|
|||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
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 {
|
var result struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
Usage struct {
|
Usage struct {
|
||||||
@@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &result); err != nil {
|
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 {
|
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
|
// 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 {
|
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)
|
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)
|
// callWithRequest single AI API call (using Request object)
|
||||||
func (client *Client) callWithRequest(req *Request) (string, error) {
|
func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||||
// Print current AI configuration
|
// Print current AI configuration
|
||||||
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
|
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))
|
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
|
||||||
|
|
||||||
// Build request body (from Request object)
|
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||||
requestBody := client.buildRequestBodyFromRequest(req)
|
|
||||||
|
|
||||||
// Serialize request body
|
|
||||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
|
||||||
url := client.hooks.buildUrl()
|
url := client.hooks.buildUrl()
|
||||||
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||||
|
|
||||||
// Create HTTP request
|
|
||||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send HTTP request
|
|
||||||
resp, err := client.httpClient.Do(httpReq)
|
resp, err := client.httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to send request: %w", err)
|
return "", fmt.Errorf("failed to send request: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Read response body
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read response: %w", err)
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check HTTP status code
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
|
||||||
result, err := client.hooks.parseMCPResponse(body)
|
result, err := client.hooks.parseMCPResponse(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
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
|
// buildRequestBodyFromRequest builds request body from Request object
|
||||||
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||||
// Convert Message to API format
|
// Convert Message to API format — must use map[string]any to support
|
||||||
messages := make([]map[string]string, 0, len(req.Messages))
|
// tool-call messages (tool_calls, tool_call_id fields).
|
||||||
|
messages := make([]map[string]any, 0, len(req.Messages))
|
||||||
for _, msg := range req.Messages {
|
for _, msg := range req.Messages {
|
||||||
messages = append(messages, map[string]string{
|
m := map[string]any{"role": msg.Role}
|
||||||
"role": msg.Role,
|
if len(msg.ToolCalls) > 0 {
|
||||||
"content": msg.Content,
|
// 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
|
// Build basic request body
|
||||||
@@ -544,3 +627,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
|||||||
|
|
||||||
return requestBody
|
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"
|
"time"
|
||||||
|
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
"nofx/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config client configuration (centralized management of all configurations)
|
// Config client configuration (centralized management of all configurations)
|
||||||
@@ -48,7 +49,7 @@ func DefaultConfig() *Config {
|
|||||||
|
|
||||||
// Default dependencies (use global logger)
|
// Default dependencies (use global logger)
|
||||||
Logger: logger.NewMCPLogger(),
|
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)
|
SetAPIKey(apiKey string, customURL string, customModel string)
|
||||||
SetTimeout(timeout time.Duration)
|
SetTimeout(timeout time.Duration)
|
||||||
CallWithMessages(systemPrompt, userPrompt string) (string, error)
|
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)
|
// clientHooks is the internal dispatch interface used to implement per-provider
|
||||||
// These methods are only used inside the package to implement dynamic dispatch
|
// 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 {
|
type clientHooks interface {
|
||||||
// Hook methods that can be overridden by subclass
|
// ── Simple CallWithMessages path ────────────────────────────────────────
|
||||||
|
|
||||||
call(systemPrompt, userPrompt string) (string, error)
|
call(systemPrompt, userPrompt string) (string, error)
|
||||||
|
|
||||||
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
||||||
|
|
||||||
|
// ── Shared request plumbing ─────────────────────────────────────────────
|
||||||
buildUrl() string
|
buildUrl() string
|
||||||
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
||||||
setAuthHeader(reqHeaders http.Header)
|
setAuthHeader(reqHeaders http.Header)
|
||||||
marshalRequestBody(requestBody map[string]any) ([]byte, error)
|
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)
|
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
|
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 (
|
const (
|
||||||
ProviderOpenAI = "openai"
|
ProviderOpenAI = "openai"
|
||||||
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
|
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||||
DefaultOpenAIModel = "gpt-5.2"
|
DefaultOpenAIModel = "gpt-5.4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OpenAIClient struct {
|
type OpenAIClient struct {
|
||||||
|
|||||||
@@ -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:
|
// Usage example:
|
||||||
// httpClient := &http.Client{Timeout: 60 * time.Second}
|
// httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||||
@@ -160,3 +164,17 @@ func WithQwenConfig(apiKey string) ClientOption {
|
|||||||
c.Model = DefaultQwenModel
|
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
|
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 {
|
type Message struct {
|
||||||
Role string `json:"role"` // "system", "user", "assistant"
|
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||||
Content string `json:"content"` // Message content
|
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
|
// 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_min_length 1024;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
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
|
# Frontend routes (SPA) with static asset caching
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
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)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
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 {
|
if len(availableCoins) == 0 {
|
||||||
return nil, fmt.Errorf("no available coins")
|
// Empty list is normal - just return empty slice, not an error
|
||||||
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by Score descending (bubble sort)
|
// Sort by Score descending (bubble sort)
|
||||||
@@ -147,10 +148,7 @@ func (c *Client) GetAvailableCoins() ([]string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(symbols) == 0 {
|
// Empty list is normal - just return empty slice, not an error
|
||||||
return nil, fmt.Errorf("no available coins")
|
|
||||||
}
|
|
||||||
|
|
||||||
return symbols, nil
|
return symbols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
183
start.sh
183
start.sh
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# NOFX AI Trading System - Docker Quick Start Script
|
# NOFX AI Trading System - Docker Management Script
|
||||||
# Usage: ./start.sh [command]
|
# Usage: ./start.sh [command]
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ detect_compose_cmd() {
|
|||||||
elif command -v docker-compose &> /dev/null; then
|
elif command -v docker-compose &> /dev/null; then
|
||||||
COMPOSE_CMD="docker-compose"
|
COMPOSE_CMD="docker-compose"
|
||||||
else
|
else
|
||||||
print_error "Docker Compose 未安装!请先安装 Docker Compose"
|
print_error "Docker Compose not found. Please install Docker Compose first."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
print_info "使用 Docker Compose 命令: $COMPOSE_CMD"
|
print_info "Using Docker Compose: $COMPOSE_CMD"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -56,12 +56,12 @@ detect_compose_cmd() {
|
|||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
check_docker() {
|
check_docker() {
|
||||||
if ! command -v docker &> /dev/null; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_compose_cmd
|
detect_compose_cmd
|
||||||
print_success "Docker 和 Docker Compose 已安装"
|
print_success "Docker and Docker Compose are installed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -69,11 +69,11 @@ check_docker() {
|
|||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
check_env() {
|
check_env() {
|
||||||
if [ ! -f ".env" ]; then
|
if [ ! -f ".env" ]; then
|
||||||
print_warning ".env 不存在,从模板复制..."
|
print_warning ".env not found, copying from template..."
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
print_info "已创建 .env 文件"
|
print_info ".env file created"
|
||||||
fi
|
fi
|
||||||
print_success "环境变量文件存在"
|
print_success "Environment file exists"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -83,15 +83,15 @@ is_env_configured() {
|
|||||||
local var_name="$1"
|
local var_name="$1"
|
||||||
local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-)
|
local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-)
|
||||||
|
|
||||||
# 去除引号
|
# Strip quotes
|
||||||
value=$(echo "$value" | tr -d '"'"'")
|
value=$(echo "$value" | tr -d '"'"'")
|
||||||
|
|
||||||
# 检查是否为空或占位符
|
# Check empty
|
||||||
if [ -z "$value" ]; then
|
if [ -z "$value" ]; then
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查是否是示例值
|
# Check placeholder values
|
||||||
case "$value" in
|
case "$value" in
|
||||||
*your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*)
|
*your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*)
|
||||||
return 1
|
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() {
|
set_env_var() {
|
||||||
local var_name="$1"
|
local var_name="$1"
|
||||||
local var_value="$2"
|
local var_value="$2"
|
||||||
|
|
||||||
# 如果变量已存在(即使是占位符),替换它
|
|
||||||
if grep -q "^${var_name}=" .env 2>/dev/null; then
|
if grep -q "^${var_name}=" .env 2>/dev/null; then
|
||||||
# macOS 和 Linux 兼容的 sed
|
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||||
else
|
else
|
||||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env
|
||||||
fi
|
fi
|
||||||
else
|
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
|
echo "${var_name}=${var_value}" >> .env
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -126,51 +127,46 @@ set_env_var() {
|
|||||||
# Validation: Encryption Keys in .env
|
# Validation: Encryption Keys in .env
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
check_encryption() {
|
check_encryption() {
|
||||||
print_info "检查加密密钥配置..."
|
print_info "Checking encryption keys..."
|
||||||
|
|
||||||
local generated=false
|
local generated=false
|
||||||
|
|
||||||
# 检查并生成 JWT_SECRET
|
|
||||||
if ! is_env_configured "JWT_SECRET"; then
|
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)
|
local jwt_secret=$(openssl rand -base64 32)
|
||||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
set_env_var "JWT_SECRET" "$jwt_secret"
|
||||||
print_success "JWT_SECRET 已生成"
|
print_success "JWT_SECRET generated"
|
||||||
generated=true
|
generated=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查并生成 DATA_ENCRYPTION_KEY
|
|
||||||
if ! is_env_configured "DATA_ENCRYPTION_KEY"; then
|
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)
|
local data_key=$(openssl rand -base64 32)
|
||||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
||||||
print_success "DATA_ENCRYPTION_KEY 已生成"
|
print_success "DATA_ENCRYPTION_KEY generated"
|
||||||
generated=true
|
generated=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查并生成 RSA_PRIVATE_KEY
|
|
||||||
if ! is_env_configured "RSA_PRIVATE_KEY"; then
|
if ! is_env_configured "RSA_PRIVATE_KEY"; then
|
||||||
print_warning "RSA_PRIVATE_KEY 未配置,正在生成..."
|
print_warning "RSA_PRIVATE_KEY not set, generating..."
|
||||||
# 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n)
|
|
||||||
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
||||||
print_success "RSA_PRIVATE_KEY 已生成"
|
print_success "RSA_PRIVATE_KEY generated"
|
||||||
generated=true
|
generated=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$generated" = true ]; then
|
if [ "$generated" = true ]; then
|
||||||
echo ""
|
echo ""
|
||||||
print_success "所有缺失的密钥已自动生成并保存到 .env"
|
print_success "Missing keys generated and saved to .env"
|
||||||
print_warning "请妥善保管 .env 文件,不要提交到版本控制系统"
|
print_warning "Keep .env safe — do not commit it to version control"
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_success "加密密钥检查完成"
|
print_success "Encryption keys OK"
|
||||||
print_info " • JWT_SECRET: OK"
|
print_info " • JWT_SECRET: OK"
|
||||||
print_info " • DATA_ENCRYPTION_KEY: OK"
|
print_info " • DATA_ENCRYPTION_KEY: OK"
|
||||||
print_info " • RSA_PRIVATE_KEY: OK"
|
print_info " • RSA_PRIVATE_KEY: OK"
|
||||||
|
|
||||||
# 修复 .env 文件权限
|
|
||||||
chmod 600 .env 2>/dev/null || true
|
chmod 600 .env 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,13 +193,12 @@ read_env_vars() {
|
|||||||
# Validation: Database Directory (data/)
|
# Validation: Database Directory (data/)
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
check_database() {
|
check_database() {
|
||||||
# Ensure data directory exists
|
|
||||||
if [ ! -d "data" ]; then
|
if [ ! -d "data" ]; then
|
||||||
print_warning "数据目录不存在,创建 data/ 目录..."
|
print_warning "Data directory missing, creating data/..."
|
||||||
install -m 700 -d data
|
install -m 700 -d data
|
||||||
print_success "已创建 data/ 目录"
|
print_success "data/ directory created"
|
||||||
else
|
else
|
||||||
print_success "数据目录存在"
|
print_success "Data directory exists"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,47 +206,58 @@ check_database() {
|
|||||||
# Service Management: Start
|
# Service Management: Start
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
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
|
read_env_vars
|
||||||
|
|
||||||
if [ ! -d "data" ]; then
|
if [ ! -d "data" ]; then
|
||||||
print_info "创建数据目录..."
|
|
||||||
install -m 700 -d data
|
install -m 700 -d data
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
|
print_info "Starting services..."
|
||||||
|
|
||||||
if [ "$1" == "--build" ]; then
|
if [ "$1" == "--build" ]; then
|
||||||
print_info "重新构建镜像..."
|
|
||||||
$COMPOSE_CMD up -d --build
|
$COMPOSE_CMD up -d --build
|
||||||
else
|
else
|
||||||
print_info "启动容器..."
|
|
||||||
$COMPOSE_CMD up -d
|
$COMPOSE_CMD up -d
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_success "服务已启动!"
|
echo ""
|
||||||
print_info "Web 界面: http://localhost:${NOFX_FRONTEND_PORT}"
|
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||||
print_info "API 端点: http://localhost:${NOFX_BACKEND_PORT}"
|
echo -e "${GREEN}║ ✅ Started! Next steps: ║${NC}"
|
||||||
print_info ""
|
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||||
print_info "查看日志: ./start.sh logs"
|
echo ""
|
||||||
print_info "停止服务: ./start.sh stop"
|
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
|
# Service Management: Stop
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
stop() {
|
stop() {
|
||||||
print_info "正在停止服务..."
|
print_info "Stopping services..."
|
||||||
$COMPOSE_CMD stop
|
$COMPOSE_CMD stop
|
||||||
print_success "服务已停止"
|
print_success "Services stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Service Management: Restart
|
# Service Management: Restart
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
restart() {
|
restart() {
|
||||||
print_info "正在重启服务..."
|
print_info "Restarting services..."
|
||||||
$COMPOSE_CMD restart
|
$COMPOSE_CMD restart
|
||||||
print_success "服务已重启"
|
print_success "Services restarted"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -271,25 +277,25 @@ logs() {
|
|||||||
status() {
|
status() {
|
||||||
read_env_vars
|
read_env_vars
|
||||||
|
|
||||||
print_info "服务状态:"
|
print_info "Service status:"
|
||||||
$COMPOSE_CMD ps
|
$COMPOSE_CMD ps
|
||||||
echo ""
|
echo ""
|
||||||
print_info "健康检查:"
|
print_info "Health check:"
|
||||||
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "后端未响应"
|
curl -s "http://localhost:${NOFX_BACKEND_PORT}/api/health" | jq '.' || echo "Backend not responding"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Maintenance: Clean (Destructive)
|
# Maintenance: Clean (Destructive)
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
clean() {
|
clean() {
|
||||||
print_warning "这将删除所有容器和数据!"
|
print_warning "This will delete all containers and data!"
|
||||||
read -p "确认删除?(yes/no): " confirm
|
read -p "Confirm? (yes/no): " confirm
|
||||||
if [ "$confirm" == "yes" ]; then
|
if [ "$confirm" == "yes" ]; then
|
||||||
print_info "正在清理..."
|
print_info "Cleaning up..."
|
||||||
$COMPOSE_CMD down -v
|
$COMPOSE_CMD down -v
|
||||||
print_success "清理完成"
|
print_success "Cleanup complete"
|
||||||
else
|
else
|
||||||
print_info "已取消"
|
print_info "Cancelled"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,77 +303,74 @@ clean() {
|
|||||||
# Maintenance: Update
|
# Maintenance: Update
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
update() {
|
update() {
|
||||||
print_info "正在更新..."
|
print_info "Updating..."
|
||||||
git pull
|
git pull
|
||||||
$COMPOSE_CMD up -d --build
|
$COMPOSE_CMD up -d --build
|
||||||
print_success "更新完成"
|
print_success "Update complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Command: Regenerate all keys (force)
|
# Command: Regenerate all keys (force)
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
regenerate_keys() {
|
regenerate_keys() {
|
||||||
print_warning "这将重新生成所有加密密钥!"
|
print_warning "This will regenerate ALL encryption keys!"
|
||||||
print_warning "如果已有加密数据,重新生成后将无法解密!"
|
print_warning "Any existing encrypted data will become unreadable!"
|
||||||
echo ""
|
echo ""
|
||||||
read -p "确认重新生成?(yes/no): " confirm
|
read -p "Confirm? (yes/no): " confirm
|
||||||
if [ "$confirm" != "yes" ]; then
|
if [ "$confirm" != "yes" ]; then
|
||||||
print_info "已取消"
|
print_info "Cancelled"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
check_env
|
check_env
|
||||||
|
|
||||||
print_info "正在生成新的密钥..."
|
print_info "Generating new keys..."
|
||||||
|
|
||||||
# 生成 JWT_SECRET
|
|
||||||
local jwt_secret=$(openssl rand -base64 32)
|
local jwt_secret=$(openssl rand -base64 32)
|
||||||
set_env_var "JWT_SECRET" "$jwt_secret"
|
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)
|
local data_key=$(openssl rand -base64 32)
|
||||||
set_env_var "DATA_ENCRYPTION_KEY" "$data_key"
|
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}')
|
local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}')
|
||||||
set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\""
|
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
|
chmod 600 .env 2>/dev/null || true
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
print_success "所有密钥已重新生成并保存到 .env"
|
print_success "All keys regenerated and saved to .env"
|
||||||
print_warning "请妥善保管 .env 文件"
|
print_warning "Keep .env safe"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
# Help: Usage Information
|
# Help: Usage Information
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
show_help() {
|
show_help() {
|
||||||
echo "NOFX AI Trading System - Docker 管理脚本"
|
echo "NOFX AI Trading System - Docker Management Script"
|
||||||
echo ""
|
echo ""
|
||||||
echo "用法: ./start.sh [command] [options]"
|
echo "Usage: ./start.sh [command] [options]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "命令:"
|
echo "Commands:"
|
||||||
echo " start [--build] 启动服务(可选:重新构建)"
|
echo " start [--build] Start services (optional: rebuild images)"
|
||||||
echo " stop 停止服务"
|
echo " stop Stop services"
|
||||||
echo " restart 重启服务"
|
echo " restart Restart services"
|
||||||
echo " logs [service] 查看日志(可选:指定服务名 backend/frontend)"
|
echo " logs [service] View logs (optional: backend / frontend)"
|
||||||
echo " status 查看服务状态"
|
echo " status Show service status"
|
||||||
echo " clean 清理所有容器和数据"
|
echo " clean Remove all containers and data"
|
||||||
echo " update 更新代码并重启"
|
echo " update Pull latest code and rebuild"
|
||||||
echo " regenerate-keys 重新生成所有加密密钥(慎用)"
|
echo " regenerate-keys Regenerate all encryption keys (destructive)"
|
||||||
echo " help 显示此帮助信息"
|
echo " help Show this help"
|
||||||
echo ""
|
echo ""
|
||||||
echo "示例:"
|
echo "Examples:"
|
||||||
echo " ./start.sh start --build # 构建并启动"
|
echo " ./start.sh start --build # Build and start"
|
||||||
echo " ./start.sh logs backend # 查看后端日志"
|
echo " ./start.sh logs backend # View backend logs"
|
||||||
echo " ./start.sh status # 查看状态"
|
echo " ./start.sh status # Check status"
|
||||||
echo ""
|
echo ""
|
||||||
echo "首次使用:"
|
echo "First time:"
|
||||||
echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成"
|
echo " Just run ./start.sh — missing keys are generated automatically"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------------
|
# ------------------------------------------------------------------------
|
||||||
@@ -408,7 +411,7 @@ main() {
|
|||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
print_error "未知命令: $1"
|
print_error "Unknown command: $1"
|
||||||
show_help
|
show_help
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -137,6 +137,19 @@ func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
|||||||
return &model, nil
|
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
|
// Update updates AI model, creates if not exists
|
||||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
// 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 {
|
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()
|
snapshot.Timestamp = snapshot.Timestamp.UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Create(snapshot).Error; err != nil {
|
// Omit ID to let PostgreSQL sequence auto-generate it
|
||||||
|
// Without this, GORM inserts ID=0 which causes duplicate key errors
|
||||||
|
if err := s.db.Omit("ID").Create(snapshot).Error; err != nil {
|
||||||
return fmt.Errorf("failed to save equity snapshot: %w", err)
|
return fmt.Errorf("failed to save equity snapshot: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -17,27 +17,28 @@ type ExchangeStore struct {
|
|||||||
|
|
||||||
// Exchange exchange configuration
|
// Exchange exchange configuration
|
||||||
type Exchange struct {
|
type Exchange struct {
|
||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
||||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||||
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
||||||
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
||||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
||||||
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
|
||||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||||
|
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||||
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
||||||
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
||||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Exchange) TableName() string { return "exchanges" }
|
func (Exchange) TableName() string { return "exchanges" }
|
||||||
@@ -173,6 +174,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
|||||||
return "Aster DEX", "dex"
|
return "Aster DEX", "dex"
|
||||||
case "lighter":
|
case "lighter":
|
||||||
return "LIGHTER DEX", "dex"
|
return "LIGHTER DEX", "dex"
|
||||||
|
case "indodax":
|
||||||
|
return "Indodax", "cex"
|
||||||
default:
|
default:
|
||||||
return exchangeType + " Exchange", "cex"
|
return exchangeType + " Exchange", "cex"
|
||||||
}
|
}
|
||||||
@@ -181,7 +184,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
|||||||
// Create creates a new exchange account with UUID
|
// Create creates a new exchange account with UUID
|
||||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||||
apiKey, secretKey, passphrase string, testnet bool,
|
apiKey, secretKey, passphrase string, testnet bool,
|
||||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
|
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||||
|
asterUser, asterSigner, asterPrivateKey,
|
||||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
||||||
|
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
@@ -207,6 +211,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
|||||||
Passphrase: crypto.EncryptedString(passphrase),
|
Passphrase: crypto.EncryptedString(passphrase),
|
||||||
Testnet: testnet,
|
Testnet: testnet,
|
||||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||||
|
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||||
AsterUser: asterUser,
|
AsterUser: asterUser,
|
||||||
AsterSigner: asterSigner,
|
AsterSigner: asterSigner,
|
||||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||||
@@ -224,19 +229,21 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
|||||||
|
|
||||||
// Update updates exchange configuration by UUID
|
// Update updates exchange configuration by UUID
|
||||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
||||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||||
|
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||||
|
|
||||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"testnet": testnet,
|
"testnet": testnet,
|
||||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||||
"aster_user": asterUser,
|
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||||
"aster_signer": asterSigner,
|
"aster_user": asterUser,
|
||||||
"lighter_wallet_addr": lighterWalletAddr,
|
"aster_signer": asterSigner,
|
||||||
"lighter_api_key_index": lighterApiKeyIndex,
|
"lighter_wallet_addr": lighterWalletAddr,
|
||||||
"updated_at": time.Now().UTC(),
|
"lighter_api_key_index": lighterApiKeyIndex,
|
||||||
|
"updated_at": time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update encrypted fields if not empty
|
// Only update encrypted fields if not empty
|
||||||
@@ -307,7 +314,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
|
|||||||
// Check if this is an old-style ID (exchange type as ID)
|
// Check if this is an old-style ID (exchange type as ID)
|
||||||
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
||||||
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
||||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
hyperliquidWalletAddr, true, // Default to Unified Account mode
|
||||||
|
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ type GridConfigModel struct {
|
|||||||
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
||||||
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
||||||
IsActive bool `json:"is_active" gorm:"default:false"`
|
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 {
|
func (GridConfigModel) TableName() string {
|
||||||
@@ -108,6 +112,11 @@ type GridInstanceModel struct {
|
|||||||
// Position adjustment due to breakout
|
// Position adjustment due to breakout
|
||||||
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
|
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"`
|
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
|
||||||
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
||||||
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
||||||
|
|||||||
@@ -3,12 +3,63 @@ package store
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision.
|
||||||
|
// For small prices (like meme coins), it preserves more decimal places.
|
||||||
|
// It detects the number of decimal places needed from the reference price(s).
|
||||||
|
func adaptivePriceRound(price float64, referencePrices ...float64) float64 {
|
||||||
|
if price == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the minimum magnitude among all prices (including the price itself)
|
||||||
|
minMagnitude := math.Abs(price)
|
||||||
|
for _, ref := range referencePrices {
|
||||||
|
if ref > 0 && ref < minMagnitude {
|
||||||
|
minMagnitude = ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine decimal places needed based on price magnitude
|
||||||
|
// For price 0.000000541, we need ~15 decimal places
|
||||||
|
// For price 0.0001, we need ~8 decimal places
|
||||||
|
// For price 1.0, we need ~4 decimal places
|
||||||
|
var multiplier float64
|
||||||
|
switch {
|
||||||
|
case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB)
|
||||||
|
multiplier = 1e15 // 15 decimal places
|
||||||
|
case minMagnitude < 0.0001: // Very small (PEPE, FLOKI)
|
||||||
|
multiplier = 1e12 // 12 decimal places
|
||||||
|
case minMagnitude < 0.01: // Small
|
||||||
|
multiplier = 1e10 // 10 decimal places
|
||||||
|
case minMagnitude < 1: // Medium
|
||||||
|
multiplier = 1e8 // 8 decimal places
|
||||||
|
default: // Large
|
||||||
|
multiplier = 1e6 // 6 decimal places
|
||||||
|
}
|
||||||
|
|
||||||
|
return math.Round(price*multiplier) / multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPriceDecimalPlaces returns the number of decimal places in a price string
|
||||||
|
func getPriceDecimalPlaces(price float64) int {
|
||||||
|
if price == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
s := strconv.FormatFloat(price, 'f', -1, 64)
|
||||||
|
idx := strings.Index(s, ".")
|
||||||
|
if idx == -1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(s) - idx - 1
|
||||||
|
}
|
||||||
|
|
||||||
// TraderStats trading statistics metrics
|
// TraderStats trading statistics metrics
|
||||||
type TraderStats struct {
|
type TraderStats struct {
|
||||||
TotalTrades int `json:"total_trades"`
|
TotalTrades int `json:"total_trades"`
|
||||||
@@ -156,7 +207,8 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
|||||||
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
|
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
|
||||||
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
|
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
|
||||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||||
|
newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice)
|
||||||
newFee := pos.Fee + addFee
|
newFee := pos.Fee + addFee
|
||||||
nowMs := time.Now().UTC().UnixMilli()
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
|
|
||||||
@@ -187,7 +239,8 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
|||||||
var newExitPrice float64
|
var newExitPrice float64
|
||||||
if newClosedQty > 0 {
|
if newClosedQty > 0 {
|
||||||
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
||||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||||
|
newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
nowMs := time.Now().UTC().UnixMilli()
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ func (pb *PositionBuilder) handleClose(
|
|||||||
var finalExitPrice float64
|
var finalExitPrice float64
|
||||||
if totalClosed > 0 {
|
if totalClosed > 0 {
|
||||||
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
||||||
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
// Use adaptive precision based on price magnitude (for meme coins with very small prices)
|
||||||
|
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
|
||||||
} else {
|
} else {
|
||||||
finalExitPrice = price
|
finalExitPrice = price
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,17 +18,18 @@ type Store struct {
|
|||||||
driver *DBDriver // Database driver for abstraction (legacy)
|
driver *DBDriver // Database driver for abstraction (legacy)
|
||||||
|
|
||||||
// Sub-stores (lazy initialization)
|
// Sub-stores (lazy initialization)
|
||||||
user *UserStore
|
user *UserStore
|
||||||
aiModel *AIModelStore
|
aiModel *AIModelStore
|
||||||
exchange *ExchangeStore
|
exchange *ExchangeStore
|
||||||
trader *TraderStore
|
trader *TraderStore
|
||||||
decision *DecisionStore
|
decision *DecisionStore
|
||||||
backtest *BacktestStore
|
backtest *BacktestStore
|
||||||
position *PositionStore
|
position *PositionStore
|
||||||
strategy *StrategyStore
|
strategy *StrategyStore
|
||||||
equity *EquityStore
|
equity *EquityStore
|
||||||
order *OrderStore
|
order *OrderStore
|
||||||
grid *GridStore
|
grid *GridStore
|
||||||
|
telegramConfig TelegramConfigStore
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -160,6 +161,9 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Grid().InitTables(); err != nil {
|
if err := s.Grid().InitTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +297,16 @@ func (s *Store) Grid() *GridStore {
|
|||||||
return s.grid
|
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
|
// Close closes database connection
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
if s.driver != nil {
|
if s.driver != nil {
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ type GridStrategyConfig struct {
|
|||||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||||||
// Use maker-only orders for lower fees
|
// Use maker-only orders for lower fees
|
||||||
UseMakerOnly bool `json:"use_maker_only"`
|
UseMakerOnly bool `json:"use_maker_only"`
|
||||||
|
// Enable automatic grid direction adjustment based on box breakouts
|
||||||
|
EnableDirectionAdjust bool `json:"enable_direction_adjust"`
|
||||||
|
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||||||
|
DirectionBiasRatio float64 `json:"direction_bias_ratio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptSectionsConfig editable sections of System Prompt
|
// PromptSectionsConfig editable sections of System Prompt
|
||||||
@@ -115,6 +119,12 @@ type CoinSourceConfig struct {
|
|||||||
UseOILow bool `json:"use_oi_low"`
|
UseOILow bool `json:"use_oi_low"`
|
||||||
// OI Low maximum count
|
// OI Low maximum count
|
||||||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||||||
|
// whether to use Hyperliquid All coins (all available perp pairs)
|
||||||
|
UseHyperAll bool `json:"use_hyper_all"`
|
||||||
|
// whether to use Hyperliquid Main coins (top N by 24h volume)
|
||||||
|
UseHyperMain bool `json:"use_hyper_main"`
|
||||||
|
// Hyperliquid Main maximum count (default 20)
|
||||||
|
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
|
||||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base32"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -18,24 +16,12 @@ type User struct {
|
|||||||
ID string `gorm:"primaryKey" json:"id"`
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
|
Email string `gorm:"uniqueIndex:idx_users_email;not null" json:"email"`
|
||||||
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
PasswordHash string `gorm:"column:password_hash;not null" json:"-"`
|
||||||
OTPSecret string `gorm:"column:otp_secret" json:"-"`
|
|
||||||
OTPVerified bool `gorm:"column:otp_verified;default:false" json:"otp_verified"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (User) TableName() string { return "users" }
|
func (User) TableName() string { return "users" }
|
||||||
|
|
||||||
// GenerateOTPSecret generates OTP secret
|
|
||||||
func GenerateOTPSecret() (string, error) {
|
|
||||||
secret := make([]byte, 20)
|
|
||||||
_, err := rand.Read(secret)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return base32.StdEncoding.EncodeToString(secret), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserStore creates a new UserStore
|
// NewUserStore creates a new UserStore
|
||||||
func NewUserStore(db *gorm.DB) *UserStore {
|
func NewUserStore(db *gorm.DB) *UserStore {
|
||||||
return &UserStore{db: db}
|
return &UserStore{db: db}
|
||||||
@@ -54,9 +40,6 @@ func (s *UserStore) initTables() error {
|
|||||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT ''`)
|
||||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`)
|
||||||
// OTP columns (added later)
|
|
||||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_secret TEXT DEFAULT ''`)
|
|
||||||
s.db.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS otp_verified BOOLEAN DEFAULT FALSE`)
|
|
||||||
|
|
||||||
// Ensure unique index exists on email (don't care about the name)
|
// Ensure unique index exists on email (don't care about the name)
|
||||||
var indexExists int64
|
var indexExists int64
|
||||||
@@ -114,9 +97,11 @@ func (s *UserStore) GetAllIDs() ([]string, error) {
|
|||||||
return userIDs, err
|
return userIDs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateOTPVerified updates OTP verification status
|
// GetAll returns all users ordered by creation time.
|
||||||
func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
func (s *UserStore) GetAll() ([]User, error) {
|
||||||
return s.db.Model(&User{}).Where("id = ?", userID).Update("otp_verified", verified).Error
|
var users []User
|
||||||
|
err := s.db.Model(&User{}).Order("created_at").Find(&users).Error
|
||||||
|
return users, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePassword updates password
|
// UpdatePassword updates password
|
||||||
@@ -138,7 +123,5 @@ func (s *UserStore) EnsureAdmin() error {
|
|||||||
ID: "admin",
|
ID: "admin",
|
||||||
Email: "admin@localhost",
|
Email: "admin@localhost",
|
||||||
PasswordHash: "",
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package aster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/accounts/abi"
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AsterTrader Aster trading platform implementation
|
// AsterTrader Aster trading platform implementation
|
||||||
@@ -1295,14 +1296,14 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]
|
|||||||
// GetClosedPnL gets recent closing trades from Aster
|
// GetClosedPnL gets recent closing trades from Aster
|
||||||
// Note: Aster does NOT have a position history API, only trade history.
|
// Note: Aster does NOT have a position history API, only trade history.
|
||||||
// This returns individual closing trades for real-time position closure detection.
|
// This returns individual closing trades for real-time position closure detection.
|
||||||
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
trades, err := t.GetTrades(startTime, limit)
|
trades, err := t.GetTrades(startTime, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter only closing trades (realizedPnl != 0)
|
// Filter only closing trades (realizedPnl != 0)
|
||||||
var records []ClosedPnLRecord
|
var records []types.ClosedPnLRecord
|
||||||
for _, trade := range trades {
|
for _, trade := range trades {
|
||||||
if trade.RealizedPnL == 0 {
|
if trade.RealizedPnL == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -1330,7 +1331,7 @@ func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLR
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
records = append(records, ClosedPnLRecord{
|
records = append(records, types.ClosedPnLRecord{
|
||||||
Symbol: trade.Symbol,
|
Symbol: trade.Symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
EntryPrice: entryPrice,
|
EntryPrice: entryPrice,
|
||||||
@@ -1366,7 +1367,7 @@ type AsterTradeRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTrades retrieves trade history from Aster
|
// GetTrades retrieves trade history from Aster
|
||||||
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
@@ -1381,24 +1382,24 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
|||||||
body, err := t.request("GET", "/fapi/v3/userTrades", params)
|
body, err := t.request("GET", "/fapi/v3/userTrades", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Aster userTrades API error: %v", err)
|
logger.Infof("⚠️ Aster userTrades API error: %v", err)
|
||||||
return []TradeRecord{}, nil
|
return []types.TradeRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var asterTrades []AsterTradeRecord
|
var asterTrades []AsterTradeRecord
|
||||||
if err := json.Unmarshal(body, &asterTrades); err != nil {
|
if err := json.Unmarshal(body, &asterTrades); err != nil {
|
||||||
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
|
logger.Infof("⚠️ Failed to parse Aster trades response: %v", err)
|
||||||
return []TradeRecord{}, nil
|
return []types.TradeRecord{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to unified TradeRecord format
|
// Convert to unified TradeRecord format
|
||||||
var result []TradeRecord
|
var result []types.TradeRecord
|
||||||
for _, at := range asterTrades {
|
for _, at := range asterTrades {
|
||||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||||
qty, _ := strconv.ParseFloat(at.Qty, 64)
|
qty, _ := strconv.ParseFloat(at.Qty, 64)
|
||||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||||
|
|
||||||
trade := TradeRecord{
|
trade := types.TradeRecord{
|
||||||
TradeID: strconv.FormatInt(at.ID, 10),
|
TradeID: strconv.FormatInt(at.ID, 10),
|
||||||
Symbol: at.Symbol,
|
Symbol: at.Symbol,
|
||||||
Side: at.Side,
|
Side: at.Side,
|
||||||
@@ -1416,7 +1417,7 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenOrders gets all open/pending orders for a symbol
|
// 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{}{
|
params := map[string]interface{}{
|
||||||
"symbol": symbol,
|
"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)
|
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []OpenOrder
|
var result []types.OpenOrder
|
||||||
for _, order := range orders {
|
for _, order := range orders {
|
||||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||||
Symbol: order.Symbol,
|
Symbol: order.Symbol,
|
||||||
Side: order.Side,
|
Side: order.Side,
|
||||||
@@ -1466,7 +1467,7 @@ func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PlaceLimitOrder places a limit order for grid trading
|
// 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
|
// Format price and quantity to correct precision
|
||||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1532,7 +1533,7 @@ func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult
|
|||||||
clientOrderID = cid
|
clientOrderID = cid
|
||||||
}
|
}
|
||||||
|
|
||||||
return &LimitOrderResult{
|
return &types.LimitOrderResult{
|
||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
ClientID: clientOrderID,
|
ClientID: clientOrderID,
|
||||||
Symbol: req.Symbol,
|
Symbol: req.Symbol,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package aster
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"nofx/trader/testutil"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -19,8 +21,8 @@ import (
|
|||||||
// AsterTraderTestSuite Aster trader test suite
|
// AsterTraderTestSuite Aster trader test suite
|
||||||
// Inherits TraderTestSuite and adds Aster specific mock logic
|
// Inherits TraderTestSuite and adds Aster specific mock logic
|
||||||
type AsterTraderTestSuite struct {
|
type AsterTraderTestSuite struct {
|
||||||
*TraderTestSuite // Embeds base test suite
|
*testutil.TraderTestSuite // Embeds base test suite
|
||||||
mockServer *httptest.Server
|
mockServer *httptest.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAsterTraderTestSuite creates Aster test suite
|
// NewAsterTraderTestSuite creates Aster test suite
|
||||||
@@ -191,7 +193,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
|||||||
privateKey, _ := crypto.GenerateKey()
|
privateKey, _ := crypto.GenerateKey()
|
||||||
|
|
||||||
// Create mock trader using mock server's URL
|
// Create mock trader using mock server's URL
|
||||||
trader := &AsterTrader{
|
traderInstance := &AsterTrader{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
user: "0x1234567890123456789012345678901234567890",
|
user: "0x1234567890123456789012345678901234567890",
|
||||||
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
signer: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||||
@@ -202,7 +204,7 @@ func NewAsterTraderTestSuite(t *testing.T) *AsterTraderTestSuite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create base suite
|
// Create base suite
|
||||||
baseSuite := NewTraderTestSuite(t, trader)
|
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||||
|
|
||||||
return &AsterTraderTestSuite{
|
return &AsterTraderTestSuite{
|
||||||
TraderTestSuite: baseSuite,
|
TraderTestSuite: baseSuite,
|
||||||
@@ -224,7 +226,7 @@ func (s *AsterTraderTestSuite) Cleanup() {
|
|||||||
|
|
||||||
// TestAsterTrader_InterfaceCompliance tests interface compliance
|
// TestAsterTrader_InterfaceCompliance tests interface compliance
|
||||||
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
|
func TestAsterTrader_InterfaceCompliance(t *testing.T) {
|
||||||
var _ Trader = (*AsterTrader)(nil)
|
var _ types.Trader = (*AsterTrader)(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
|
// TestAsterTrader_CommonInterface runs all common interface tests using test suite
|
||||||
@@ -277,21 +279,21 @@ func TestNewAsterTrader(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
trader, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
at, err := NewAsterTrader(tt.user, tt.signer, tt.privateKeyHex)
|
||||||
|
|
||||||
if tt.wantError {
|
if tt.wantError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
if tt.errorContains != "" {
|
if tt.errorContains != "" {
|
||||||
assert.Contains(t, err.Error(), tt.errorContains)
|
assert.Contains(t, err.Error(), tt.errorContains)
|
||||||
}
|
}
|
||||||
assert.Nil(t, trader)
|
assert.Nil(t, at)
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, trader)
|
assert.NotNil(t, at)
|
||||||
if trader != nil {
|
if at != nil {
|
||||||
assert.Equal(t, tt.user, trader.user)
|
assert.Equal(t, tt.user, at.user)
|
||||||
assert.Equal(t, tt.signer, trader.signer)
|
assert.Equal(t, tt.signer, at.signer)
|
||||||
assert.NotNil(t, trader.privateKey)
|
assert.NotNil(t, at.privateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -4,12 +4,22 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"nofx/kernel"
|
|
||||||
"nofx/experience"
|
"nofx/experience"
|
||||||
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/trader/aster"
|
||||||
|
"nofx/trader/binance"
|
||||||
|
"nofx/trader/bitget"
|
||||||
|
"nofx/trader/bybit"
|
||||||
|
"nofx/trader/gate"
|
||||||
|
"nofx/trader/hyperliquid"
|
||||||
|
"nofx/trader/indodax"
|
||||||
|
"nofx/trader/kucoin"
|
||||||
|
"nofx/trader/lighter"
|
||||||
|
"nofx/trader/okx"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,7 +33,7 @@ type AutoTraderConfig struct {
|
|||||||
AIModel string // AI model: "qwen" or "deepseek"
|
AIModel string // AI model: "qwen" or "deepseek"
|
||||||
|
|
||||||
// Trading platform selection
|
// Trading platform selection
|
||||||
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "hyperliquid", "aster" or "lighter"
|
Exchange string // Exchange type: "binance", "bybit", "okx", "bitget", "gate", "hyperliquid", "aster" or "lighter"
|
||||||
ExchangeID string // Exchange account UUID (for multi-account support)
|
ExchangeID string // Exchange account UUID (for multi-account support)
|
||||||
|
|
||||||
// Binance API configuration
|
// Binance API configuration
|
||||||
@@ -35,19 +45,33 @@ type AutoTraderConfig struct {
|
|||||||
BybitSecretKey string
|
BybitSecretKey string
|
||||||
|
|
||||||
// OKX API configuration
|
// OKX API configuration
|
||||||
OKXAPIKey string
|
OKXAPIKey string
|
||||||
OKXSecretKey string
|
OKXSecretKey string
|
||||||
OKXPassphrase string
|
OKXPassphrase string
|
||||||
|
|
||||||
// Bitget API configuration
|
// Bitget API configuration
|
||||||
BitgetAPIKey string
|
BitgetAPIKey string
|
||||||
BitgetSecretKey string
|
BitgetSecretKey string
|
||||||
BitgetPassphrase string
|
BitgetPassphrase string
|
||||||
|
|
||||||
|
// Gate API configuration
|
||||||
|
GateAPIKey string
|
||||||
|
GateSecretKey string
|
||||||
|
|
||||||
|
// KuCoin API configuration
|
||||||
|
KuCoinAPIKey string
|
||||||
|
KuCoinSecretKey string
|
||||||
|
KuCoinPassphrase string
|
||||||
|
|
||||||
|
// Indodax API configuration
|
||||||
|
IndodaxAPIKey string
|
||||||
|
IndodaxSecretKey string
|
||||||
|
|
||||||
// Hyperliquid configuration
|
// Hyperliquid configuration
|
||||||
HyperliquidPrivateKey string
|
HyperliquidPrivateKey string
|
||||||
HyperliquidWalletAddr string
|
HyperliquidWalletAddr string
|
||||||
HyperliquidTestnet bool
|
HyperliquidTestnet bool
|
||||||
|
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
|
||||||
|
|
||||||
// Aster configuration
|
// Aster configuration
|
||||||
AsterUser string // Aster main wallet address
|
AsterUser string // Aster main wallet address
|
||||||
@@ -103,9 +127,9 @@ type AutoTrader struct {
|
|||||||
config AutoTraderConfig
|
config AutoTraderConfig
|
||||||
trader Trader // Use Trader interface (supports multiple platforms)
|
trader Trader // Use Trader interface (supports multiple platforms)
|
||||||
mcpClient mcp.AIClient
|
mcpClient mcp.AIClient
|
||||||
store *store.Store // Data storage (decision records, etc.)
|
store *store.Store // Data storage (decision records, etc.)
|
||||||
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||||
cycleNumber int // Current cycle number
|
cycleNumber int // Current cycle number
|
||||||
initialBalance float64
|
initialBalance float64
|
||||||
dailyPnL float64
|
dailyPnL float64
|
||||||
customPrompt string // Custom trading strategy prompt
|
customPrompt string // Custom trading strategy prompt
|
||||||
@@ -177,6 +201,26 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName)
|
||||||
logger.Infof("🤖 [%s] Using OpenAI", config.Name)
|
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":
|
case "qwen":
|
||||||
mcpClient = mcp.NewQwenClient()
|
mcpClient = mcp.NewQwenClient()
|
||||||
apiKey := config.QwenKey
|
apiKey := config.QwenKey
|
||||||
@@ -224,25 +268,31 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
switch config.Exchange {
|
switch config.Exchange {
|
||||||
case "binance":
|
case "binance":
|
||||||
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
|
logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name)
|
||||||
trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
trader = binance.NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID)
|
||||||
case "bybit":
|
case "bybit":
|
||||||
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
|
logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name)
|
||||||
trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
trader = bybit.NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey)
|
||||||
case "okx":
|
case "okx":
|
||||||
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
|
logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name)
|
||||||
trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
trader = okx.NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase)
|
||||||
case "bitget":
|
case "bitget":
|
||||||
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
|
logger.Infof("🏦 [%s] Using Bitget Futures trading", config.Name)
|
||||||
trader = NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
trader = bitget.NewBitgetTrader(config.BitgetAPIKey, config.BitgetSecretKey, config.BitgetPassphrase)
|
||||||
|
case "gate":
|
||||||
|
logger.Infof("🏦 [%s] Using Gate.io Futures trading", config.Name)
|
||||||
|
trader = gate.NewGateTrader(config.GateAPIKey, config.GateSecretKey)
|
||||||
|
case "kucoin":
|
||||||
|
logger.Infof("🏦 [%s] Using KuCoin Futures trading", config.Name)
|
||||||
|
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
|
||||||
case "hyperliquid":
|
case "hyperliquid":
|
||||||
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
|
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
|
||||||
trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet)
|
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
|
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
|
||||||
}
|
}
|
||||||
case "aster":
|
case "aster":
|
||||||
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
|
logger.Infof("🏦 [%s] Using Aster trading", config.Name)
|
||||||
trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
trader, err = aster.NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
|
return nil, fmt.Errorf("failed to initialize Aster trader: %w", err)
|
||||||
}
|
}
|
||||||
@@ -254,7 +304,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lighter only supports mainnet (testnet disabled)
|
// Lighter only supports mainnet (testnet disabled)
|
||||||
trader, err = NewLighterTraderV2(
|
trader, err = lighter.NewLighterTraderV2(
|
||||||
config.LighterWalletAddr,
|
config.LighterWalletAddr,
|
||||||
config.LighterAPIKeyPrivateKey,
|
config.LighterAPIKeyPrivateKey,
|
||||||
config.LighterAPIKeyIndex,
|
config.LighterAPIKeyIndex,
|
||||||
@@ -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)
|
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||||||
}
|
}
|
||||||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||||||
|
case "indodax":
|
||||||
|
logger.Infof("🏦 [%s] Using Indodax Spot trading", config.Name)
|
||||||
|
trader = indodax.NewIndodaxTrader(config.IndodaxAPIKey, config.IndodaxSecretKey)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||||
}
|
}
|
||||||
@@ -363,7 +416,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Lighter order sync if using Lighter exchange
|
// Start Lighter order sync if using Lighter exchange
|
||||||
if at.exchange == "lighter" {
|
if at.exchange == "lighter" {
|
||||||
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
|
if lighterTrader, ok := at.trader.(*lighter.LighterTraderV2); ok && at.store != nil {
|
||||||
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
lighterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Lighter order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -371,7 +424,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Hyperliquid order sync if using Hyperliquid exchange
|
// Start Hyperliquid order sync if using Hyperliquid exchange
|
||||||
if at.exchange == "hyperliquid" {
|
if at.exchange == "hyperliquid" {
|
||||||
if hyperliquidTrader, ok := at.trader.(*HyperliquidTrader); ok && at.store != nil {
|
if hyperliquidTrader, ok := at.trader.(*hyperliquid.HyperliquidTrader); ok && at.store != nil {
|
||||||
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
hyperliquidTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Hyperliquid order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -379,7 +432,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Bybit order sync if using Bybit exchange
|
// Start Bybit order sync if using Bybit exchange
|
||||||
if at.exchange == "bybit" {
|
if at.exchange == "bybit" {
|
||||||
if bybitTrader, ok := at.trader.(*BybitTrader); ok && at.store != nil {
|
if bybitTrader, ok := at.trader.(*bybit.BybitTrader); ok && at.store != nil {
|
||||||
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
bybitTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Bybit order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -387,7 +440,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start OKX order sync if using OKX exchange
|
// Start OKX order sync if using OKX exchange
|
||||||
if at.exchange == "okx" {
|
if at.exchange == "okx" {
|
||||||
if okxTrader, ok := at.trader.(*OKXTrader); ok && at.store != nil {
|
if okxTrader, ok := at.trader.(*okx.OKXTrader); ok && at.store != nil {
|
||||||
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
okxTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] OKX order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -395,7 +448,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Bitget order sync if using Bitget exchange
|
// Start Bitget order sync if using Bitget exchange
|
||||||
if at.exchange == "bitget" {
|
if at.exchange == "bitget" {
|
||||||
if bitgetTrader, ok := at.trader.(*BitgetTrader); ok && at.store != nil {
|
if bitgetTrader, ok := at.trader.(*bitget.BitgetTrader); ok && at.store != nil {
|
||||||
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
bitgetTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Bitget order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -403,7 +456,7 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Aster order sync if using Aster exchange
|
// Start Aster order sync if using Aster exchange
|
||||||
if at.exchange == "aster" {
|
if at.exchange == "aster" {
|
||||||
if asterTrader, ok := at.trader.(*AsterTrader); ok && at.store != nil {
|
if asterTrader, ok := at.trader.(*aster.AsterTrader); ok && at.store != nil {
|
||||||
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
asterTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Aster order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
@@ -411,12 +464,28 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Start Binance order sync if using Binance exchange
|
// Start Binance order sync if using Binance exchange
|
||||||
if at.exchange == "binance" {
|
if at.exchange == "binance" {
|
||||||
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
|
if binanceTrader, ok := at.trader.(*binance.FuturesTrader); ok && at.store != nil {
|
||||||
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
|
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start Gate order sync if using Gate exchange
|
||||||
|
if at.exchange == "gate" {
|
||||||
|
if gateTrader, ok := at.trader.(*gate.GateTrader); ok && at.store != nil {
|
||||||
|
gateTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
|
logger.Infof("🔄 [%s] Gate order+position sync enabled (every 30s)", at.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start KuCoin order sync if using KuCoin exchange
|
||||||
|
if at.exchange == "kucoin" {
|
||||||
|
if kucoinTrader, ok := at.trader.(*kucoin.KuCoinTrader); ok && at.store != nil {
|
||||||
|
kucoinTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
|
||||||
|
logger.Infof("🔄 [%s] KuCoin order+position sync enabled (every 30s)", at.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(at.config.ScanInterval)
|
ticker := time.NewTicker(at.config.ScanInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -534,15 +603,26 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
return fmt.Errorf("failed to build trading context: %w", err)
|
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 {
|
if len(ctx.CandidateCoins) == 0 {
|
||||||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
|
||||||
at.saveEquitySnapshot(ctx)
|
|
||||||
|
|
||||||
logger.Info(strings.Repeat("=", 70))
|
logger.Info(strings.Repeat("=", 70))
|
||||||
for _, coin := range ctx.CandidateCoins {
|
for _, coin := range ctx.CandidateCoins {
|
||||||
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
record.CandidateCoins = append(record.CandidateCoins, coin.Symbol)
|
||||||
@@ -821,14 +901,19 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
// 3. Use strategy engine to get candidate coins (must have strategy engine)
|
||||||
|
var candidateCoins []kernel.CandidateCoin
|
||||||
if at.strategyEngine == nil {
|
if at.strategyEngine == nil {
|
||||||
return nil, fmt.Errorf("trader has no strategy engine configured")
|
logger.Infof("⚠️ [%s] No strategy engine configured, skipping candidate coins", at.name)
|
||||||
|
} else {
|
||||||
|
coins, err := at.strategyEngine.GetCandidateCoins()
|
||||||
|
if err != nil {
|
||||||
|
// Log warning but don't fail - equity snapshot should still be saved
|
||||||
|
logger.Infof("⚠️ [%s] Failed to get candidate coins: %v (will use empty list)", at.name, err)
|
||||||
|
} else {
|
||||||
|
candidateCoins = coins
|
||||||
|
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
candidateCoins, err := at.strategyEngine.GetCandidateCoins()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get candidate coins: %w", err)
|
|
||||||
}
|
|
||||||
logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins))
|
|
||||||
|
|
||||||
// 4. Calculate total P&L
|
// 4. Calculate total P&L
|
||||||
totalPnL := totalEquity - at.initialBalance
|
totalPnL := totalEquity - at.initialBalance
|
||||||
@@ -1050,7 +1135,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
marketData, err := market.Get(decision.Symbol)
|
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1167,7 +1252,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, acti
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
marketData, err := market.Get(decision.Symbol)
|
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1266,7 +1351,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, acti
|
|||||||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
marketData, err := market.Get(decision.Symbol)
|
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1330,7 +1415,7 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, act
|
|||||||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
marketData, err := market.Get(decision.Symbol)
|
marketData, err := market.GetWithExchange(decision.Symbol, at.exchange)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1926,7 +2011,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
|||||||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||||||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||||||
switch at.exchange {
|
switch at.exchange {
|
||||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate":
|
||||||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2124,22 +2209,22 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
|||||||
normalizedSymbol := market.Normalize(symbol)
|
normalizedSymbol := market.Normalize(symbol)
|
||||||
|
|
||||||
fill := &store.TraderFill{
|
fill := &store.TraderFill{
|
||||||
TraderID: at.id,
|
TraderID: at.id,
|
||||||
ExchangeID: at.exchangeID,
|
ExchangeID: at.exchangeID,
|
||||||
ExchangeType: at.exchange,
|
ExchangeType: at.exchange,
|
||||||
OrderID: orderRecordID,
|
OrderID: orderRecordID,
|
||||||
ExchangeOrderID: exchangeOrderID,
|
ExchangeOrderID: exchangeOrderID,
|
||||||
ExchangeTradeID: tradeID,
|
ExchangeTradeID: tradeID,
|
||||||
Symbol: normalizedSymbol,
|
Symbol: normalizedSymbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
Price: price,
|
Price: price,
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
QuoteQuantity: price * quantity,
|
QuoteQuantity: price * quantity,
|
||||||
Commission: fee,
|
Commission: fee,
|
||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0, // Will be calculated for close orders
|
RealizedPnL: 0, // Will be calculated for close orders
|
||||||
IsMaker: false, // Market orders are usually taker
|
IsMaker: false, // Market orders are usually taker
|
||||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate realized PnL for close orders
|
// Calculate realized PnL for close orders
|
||||||
@@ -2267,4 +2352,3 @@ func getSideFromAction(action string) string {
|
|||||||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||||
return at.trader.GetOpenOrders(symbol)
|
return at.trader.GetOpenOrders(symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,14 +65,20 @@ type GridState struct {
|
|||||||
|
|
||||||
// Current regime level
|
// Current regime level
|
||||||
CurrentRegimeLevel string
|
CurrentRegimeLevel string
|
||||||
|
|
||||||
|
// Grid direction adjustment
|
||||||
|
CurrentDirection market.GridDirection
|
||||||
|
DirectionChangedAt time.Time
|
||||||
|
DirectionChangeCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewGridState creates a new grid state
|
// NewGridState creates a new grid state
|
||||||
func NewGridState(config *store.GridStrategyConfig) *GridState {
|
func NewGridState(config *store.GridStrategyConfig) *GridState {
|
||||||
return &GridState{
|
return &GridState{
|
||||||
Config: config,
|
Config: config,
|
||||||
Levels: make([]kernel.GridLevelInfo, 0),
|
Levels: make([]kernel.GridLevelInfo, 0),
|
||||||
OrderBook: make(map[string]int),
|
OrderBook: make(map[string]int),
|
||||||
|
CurrentDirection: market.GridDirectionNeutral,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +331,17 @@ func (at *AutoTrader) checkBoxBreakout() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Take action based on breakout level
|
// 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)
|
return at.executeBreakoutAction(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +374,38 @@ func (at *AutoTrader) executeBreakoutAction(action BreakoutAction) error {
|
|||||||
logger.Infof("Failed to cancel orders: %v", err)
|
logger.Infof("Failed to cancel orders: %v", err)
|
||||||
}
|
}
|
||||||
return at.closeAllPositions()
|
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
|
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
|
// closeAllPositions closes all open positions for the grid symbol
|
||||||
func (at *AutoTrader) closeAllPositions() error {
|
func (at *AutoTrader) closeAllPositions() error {
|
||||||
gridConfig := at.config.StrategyConfig.GridConfig
|
gridConfig := at.config.StrategyConfig.GridConfig
|
||||||
@@ -410,10 +453,16 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
|||||||
breakoutLevel := at.gridState.BreakoutLevel
|
breakoutLevel := at.gridState.BreakoutLevel
|
||||||
isPaused := at.gridState.IsPaused
|
isPaused := at.gridState.IsPaused
|
||||||
positionReduction := at.gridState.PositionReductionPct
|
positionReduction := at.gridState.PositionReductionPct
|
||||||
|
currentDirection := at.gridState.CurrentDirection
|
||||||
at.gridState.mu.RUnlock()
|
at.gridState.mu.RUnlock()
|
||||||
|
|
||||||
// Only check if we had a breakout
|
// Only check if we had a breakout or non-neutral direction
|
||||||
if breakoutLevel == string(market.BreakoutNone) && positionReduction == 0 && !isPaused {
|
needsRecoveryCheck := breakoutLevel != string(market.BreakoutNone) ||
|
||||||
|
positionReduction != 0 ||
|
||||||
|
isPaused ||
|
||||||
|
(gridConfig.EnableDirectionAdjust && currentDirection != market.GridDirectionNeutral)
|
||||||
|
|
||||||
|
if !needsRecoveryCheck {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +485,18 @@ func (at *AutoTrader) checkFalseBreakoutRecovery() error {
|
|||||||
at.gridState.mu.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,6 +631,128 @@ func (at *AutoTrader) initializeGridLevels(currentPrice float64, config *store.G
|
|||||||
}
|
}
|
||||||
|
|
||||||
at.gridState.Levels = levels
|
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
|
// RunGridCycle executes one grid trading cycle
|
||||||
@@ -1370,6 +1553,85 @@ func (at *AutoTrader) initializeGridLevelsLocked(currentPrice float64, config *s
|
|||||||
}
|
}
|
||||||
|
|
||||||
at.gridState.Levels = levels
|
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
|
// GridRiskInfo contains risk information for frontend display
|
||||||
@@ -1397,6 +1659,11 @@ type GridRiskInfo struct {
|
|||||||
|
|
||||||
BreakoutLevel string `json:"breakout_level"`
|
BreakoutLevel string `json:"breakout_level"`
|
||||||
BreakoutDirection string `json:"breakout_direction"`
|
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
|
// GetGridRiskInfo returns current risk information for frontend display
|
||||||
@@ -1513,6 +1780,10 @@ func (at *AutoTrader) GetGridRiskInfo() *GridRiskInfo {
|
|||||||
|
|
||||||
BreakoutLevel: at.gridState.BreakoutLevel,
|
BreakoutLevel: at.gridState.BreakoutLevel,
|
||||||
BreakoutDirection: at.gridState.BreakoutDirection,
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"nofx/hook"
|
"nofx/hook"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
"nofx/trader/types"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -718,7 +719,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
|||||||
|
|
||||||
// PlaceLimitOrder places a limit order for grid trading
|
// PlaceLimitOrder places a limit order for grid trading
|
||||||
// This implements the GridTrader interface for FuturesTrader
|
// 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
|
// Format quantity to correct precision
|
||||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||||
if err != nil {
|
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",
|
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||||
|
|
||||||
return &LimitOrderResult{
|
return &types.LimitOrderResult{
|
||||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||||
ClientID: order.ClientOrderID,
|
ClientID: order.ClientOrderID,
|
||||||
Symbol: order.Symbol,
|
Symbol: order.Symbol,
|
||||||
@@ -896,8 +897,8 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenOrders gets all open/pending orders for a symbol
|
// GetOpenOrders gets all open/pending orders for a symbol
|
||||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||||
var result []OpenOrder
|
var result []types.OpenOrder
|
||||||
|
|
||||||
// 1. Get legacy open orders
|
// 1. Get legacy open orders
|
||||||
orders, err := t.client.NewListOpenOrdersService().
|
orders, err := t.client.NewListOpenOrdersService().
|
||||||
@@ -913,7 +914,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||||
Symbol: order.Symbol,
|
Symbol: order.Symbol,
|
||||||
Side: string(order.Side),
|
Side: string(order.Side),
|
||||||
@@ -936,7 +937,7 @@ func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||||||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||||||
Symbol: algoOrder.Symbol,
|
Symbol: algoOrder.Symbol,
|
||||||
Side: string(algoOrder.Side),
|
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.
|
// Note: Binance does NOT have a position history API, only trade history.
|
||||||
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
|
// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection.
|
||||||
// NOT suitable for historical position reconstruction - use only for matching recent closures.
|
// NOT suitable for historical position reconstruction - use only for matching recent closures.
|
||||||
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
trades, err := t.GetTrades(startTime, limit)
|
trades, err := t.GetTrades(startTime, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
|
// Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord
|
||||||
var records []ClosedPnLRecord
|
var records []types.ClosedPnLRecord
|
||||||
for _, trade := range trades {
|
for _, trade := range trades {
|
||||||
if trade.RealizedPnL == 0 {
|
if trade.RealizedPnL == 0 {
|
||||||
continue // Skip opening trades
|
continue // Skip opening trades
|
||||||
@@ -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,
|
Symbol: trade.Symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
EntryPrice: entryPrice,
|
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
|
// GetTrades retrieves trade history from Binance Futures using Income API
|
||||||
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
|
// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead
|
||||||
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
@@ -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)
|
return nil, fmt.Errorf("failed to get income history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trades []TradeRecord
|
var trades []types.TradeRecord
|
||||||
for _, income := range incomes {
|
for _, income := range incomes {
|
||||||
pnl, _ := strconv.ParseFloat(income.Income, 64)
|
pnl, _ := strconv.ParseFloat(income.Income, 64)
|
||||||
if pnl == 0 {
|
if pnl == 0 {
|
||||||
@@ -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
|
// Income API doesn't provide full trade details, create a minimal record
|
||||||
// This is mainly used for detecting recent closures, not historical reconstruction
|
// This is mainly used for detecting recent closures, not historical reconstruction
|
||||||
trade := TradeRecord{
|
trade := types.TradeRecord{
|
||||||
TradeID: strconv.FormatInt(income.TranID, 10),
|
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||||
Symbol: income.Symbol,
|
Symbol: income.Symbol,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
@@ -1347,7 +1348,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
|||||||
|
|
||||||
// GetTradesForSymbol retrieves trade history for a specific symbol
|
// GetTradesForSymbol retrieves trade history for a specific symbol
|
||||||
// This is more reliable than using Income API which may have delays
|
// This is more reliable than using Income API which may have delays
|
||||||
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) {
|
func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
@@ -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)
|
return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trades []TradeRecord
|
var trades []types.TradeRecord
|
||||||
for _, at := range accountTrades {
|
for _, at := range accountTrades {
|
||||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||||
|
|
||||||
trade := TradeRecord{
|
trade := types.TradeRecord{
|
||||||
TradeID: strconv.FormatInt(at.ID, 10),
|
TradeID: strconv.FormatInt(at.ID, 10),
|
||||||
Symbol: at.Symbol,
|
Symbol: at.Symbol,
|
||||||
Side: string(at.Side),
|
Side: string(at.Side),
|
||||||
@@ -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
|
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
|
||||||
// This is used for incremental sync - only fetch new trades since last sync
|
// This is used for incremental sync - only fetch new trades since last sync
|
||||||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
|
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]types.TradeRecord, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
@@ -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)
|
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trades []TradeRecord
|
var trades []types.TradeRecord
|
||||||
for _, at := range accountTrades {
|
for _, at := range accountTrades {
|
||||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||||
|
|
||||||
trade := TradeRecord{
|
trade := types.TradeRecord{
|
||||||
TradeID: strconv.FormatInt(at.ID, 10),
|
TradeID: strconv.FormatInt(at.ID, 10),
|
||||||
Symbol: at.Symbol,
|
Symbol: at.Symbol,
|
||||||
Side: string(at.Side),
|
Side: string(at.Side),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package binance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/adshao/go-binance/v2/futures"
|
"github.com/adshao/go-binance/v2/futures"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"nofx/trader/testutil"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -20,8 +22,8 @@ import (
|
|||||||
// BinanceFuturesTestSuite Binance Futures trader test suite
|
// BinanceFuturesTestSuite Binance Futures trader test suite
|
||||||
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
|
// Inherits TraderTestSuite and adds Binance Futures specific mock logic
|
||||||
type BinanceFuturesTestSuite struct {
|
type BinanceFuturesTestSuite struct {
|
||||||
*TraderTestSuite // Embeds base test suite
|
*testutil.TraderTestSuite // Embeds base test suite
|
||||||
mockServer *httptest.Server
|
mockServer *httptest.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBinanceFuturesTestSuite Creates Binance Futures test suite
|
// NewBinanceFuturesTestSuite Creates Binance Futures test suite
|
||||||
@@ -270,13 +272,13 @@ func NewBinanceFuturesTestSuite(t *testing.T) *BinanceFuturesTestSuite {
|
|||||||
client.HTTPClient = mockServer.Client()
|
client.HTTPClient = mockServer.Client()
|
||||||
|
|
||||||
// Create FuturesTrader
|
// Create FuturesTrader
|
||||||
trader := &FuturesTrader{
|
traderInstance := &FuturesTrader{
|
||||||
client: client,
|
client: client,
|
||||||
cacheDuration: 0, // disable cache for testing
|
cacheDuration: 0, // disable cache for testing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create base suite
|
// Create base suite
|
||||||
baseSuite := NewTraderTestSuite(t, trader)
|
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||||
|
|
||||||
return &BinanceFuturesTestSuite{
|
return &BinanceFuturesTestSuite{
|
||||||
TraderTestSuite: baseSuite,
|
TraderTestSuite: baseSuite,
|
||||||
@@ -298,7 +300,7 @@ func (s *BinanceFuturesTestSuite) Cleanup() {
|
|||||||
|
|
||||||
// TestFuturesTrader_InterfaceCompliance tests interface compliance
|
// TestFuturesTrader_InterfaceCompliance tests interface compliance
|
||||||
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
|
func TestFuturesTrader_InterfaceCompliance(t *testing.T) {
|
||||||
var _ Trader = (*FuturesTrader)(nil)
|
var _ types.Trader = (*FuturesTrader)(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
|
// TestFuturesTrader_CommonInterface runs all common interface tests using test suite
|
||||||
@@ -343,20 +345,20 @@ func TestNewFuturesTrader(t *testing.T) {
|
|||||||
defer mockServer.Close()
|
defer mockServer.Close()
|
||||||
|
|
||||||
// Test successful creation
|
// Test successful creation
|
||||||
trader := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
t1 := NewFuturesTrader("test_api_key", "test_secret_key", "test_user")
|
||||||
|
|
||||||
// Modify client to use mock server
|
// Modify client to use mock server
|
||||||
trader.client.BaseURL = mockServer.URL
|
t1.client.BaseURL = mockServer.URL
|
||||||
trader.client.HTTPClient = mockServer.Client()
|
t1.client.HTTPClient = mockServer.Client()
|
||||||
|
|
||||||
assert.NotNil(t, trader)
|
assert.NotNil(t, t1)
|
||||||
assert.NotNil(t, trader.client)
|
assert.NotNil(t, t1.client)
|
||||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
assert.Equal(t, 15*time.Second, t1.cacheDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCalculatePositionSize tests position size calculation
|
// TestCalculatePositionSize tests position size calculation
|
||||||
func TestCalculatePositionSize(t *testing.T) {
|
func TestCalculatePositionSize(t *testing.T) {
|
||||||
trader := &FuturesTrader{}
|
ft := &FuturesTrader{}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -394,7 +396,7 @@ func TestCalculatePositionSize(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
quantity := trader.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
quantity := ft.CalculatePositionSize(tt.balance, tt.riskPercent, tt.price, tt.leverage)
|
||||||
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
|
assert.InDelta(t, tt.wantQuantity, quantity, 0.0001, "calculated position size is incorrect")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
package trader
|
package binance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
"nofx/trader/types"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -126,11 +127,11 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols)
|
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols)
|
||||||
|
|
||||||
// Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols)
|
// Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols)
|
||||||
var allTrades []TradeRecord
|
var allTrades []types.TradeRecord
|
||||||
var failedSymbols []string
|
var failedSymbols []string
|
||||||
apiCalls := 0
|
apiCalls := 0
|
||||||
for _, symbol := range changedSymbols {
|
for _, symbol := range changedSymbols {
|
||||||
var trades []TradeRecord
|
var trades []types.TradeRecord
|
||||||
var queryErr error
|
var queryErr error
|
||||||
|
|
||||||
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
|
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package binance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package binance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package binance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package bitget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -48,52 +48,82 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
|||||||
return nil, fmt.Errorf("failed to get fill history: %w", err)
|
return nil, fmt.Errorf("failed to get fill history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
FillList []struct {
|
// Bitget fill structure - supports both one-way and hedge mode
|
||||||
TradeID string `json:"tradeId"`
|
type BitgetFill struct {
|
||||||
Symbol string `json:"symbol"`
|
TradeID string `json:"tradeId"`
|
||||||
OrderID string `json:"orderId"`
|
Symbol string `json:"symbol"`
|
||||||
Side string `json:"side"` // buy, sell
|
OrderID string `json:"orderId"`
|
||||||
Price string `json:"price"` // Fill price
|
Side string `json:"side"` // buy, sell
|
||||||
BaseVolume string `json:"baseVolume"` // Fill size in base currency
|
Price string `json:"price"` // Fill price
|
||||||
Fee string `json:"fee"` // Fee (negative for cost)
|
BaseVolume string `json:"baseVolume"` // Fill size in base currency
|
||||||
FeeCcy string `json:"feeCcy"` // Fee currency
|
Profit string `json:"profit"` // Realized PnL
|
||||||
Profit string `json:"profit"` // Realized PnL
|
CTime string `json:"cTime"` // Fill time (ms)
|
||||||
CTime string `json:"cTime"` // Fill time (ms)
|
TradeSide string `json:"tradeSide"` // one-way: buy_single/sell_single, hedge: open/close
|
||||||
TradeSide string `json:"tradeSide"` // open, close
|
FeeDetail []struct {
|
||||||
} `json:"fillList"`
|
FeeCoin string `json:"feeCoin"`
|
||||||
|
TotalFee string `json:"totalFee"`
|
||||||
|
} `json:"feeDetail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &resp); err != nil {
|
// Try parsing as wrapped response first (fillList field)
|
||||||
return nil, fmt.Errorf("failed to parse fills: %w", err)
|
var wrappedResp struct {
|
||||||
|
FillList []BitgetFill `json:"fillList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
trades := make([]BitgetTrade, 0, len(resp.FillList))
|
// Try direct array format (Bitget V2 API returns data as direct array)
|
||||||
|
var directFills []BitgetFill
|
||||||
|
|
||||||
for _, fill := range resp.FillList {
|
// Try wrapped format first
|
||||||
|
if err := json.Unmarshal(data, &wrappedResp); err == nil && len(wrappedResp.FillList) > 0 {
|
||||||
|
logger.Infof("🔍 Bitget: parsed as wrapped format, fillList count: %d", len(wrappedResp.FillList))
|
||||||
|
directFills = wrappedResp.FillList
|
||||||
|
} else {
|
||||||
|
// Try direct array format
|
||||||
|
if err := json.Unmarshal(data, &directFills); err != nil {
|
||||||
|
logger.Infof("⚠️ Bitget fill-history parse failed, raw: %s", string(data))
|
||||||
|
return nil, fmt.Errorf("failed to parse fills: %w", err)
|
||||||
|
}
|
||||||
|
logger.Infof("🔍 Bitget: parsed as direct array, fills count: %d", len(directFills))
|
||||||
|
}
|
||||||
|
|
||||||
|
trades := make([]BitgetTrade, 0, len(directFills))
|
||||||
|
|
||||||
|
for _, fill := range directFills {
|
||||||
fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
|
fillPrice, _ := strconv.ParseFloat(fill.Price, 64)
|
||||||
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
|
fillQty, _ := strconv.ParseFloat(fill.BaseVolume, 64)
|
||||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
|
||||||
profit, _ := strconv.ParseFloat(fill.Profit, 64)
|
profit, _ := strconv.ParseFloat(fill.Profit, 64)
|
||||||
cTime, _ := strconv.ParseInt(fill.CTime, 10, 64)
|
cTime, _ := strconv.ParseInt(fill.CTime, 10, 64)
|
||||||
|
|
||||||
|
// Extract fee from feeDetail array (Bitget V2 API)
|
||||||
|
var fee float64
|
||||||
|
var feeAsset string
|
||||||
|
if len(fill.FeeDetail) > 0 {
|
||||||
|
fee, _ = strconv.ParseFloat(fill.FeeDetail[0].TotalFee, 64)
|
||||||
|
feeAsset = fill.FeeDetail[0].FeeCoin
|
||||||
|
}
|
||||||
|
|
||||||
// Determine order action based on side and tradeSide
|
// Determine order action based on side and tradeSide
|
||||||
// Bitget one-way mode:
|
// Bitget one-way mode: buy_single (open long), sell_single (close long)
|
||||||
// - buy + open = open long
|
// Bitget hedge mode: open + buy = open_long, close + sell = close_long
|
||||||
// - sell + open = open short
|
|
||||||
// - sell + close = close long
|
|
||||||
// - buy + close = close short
|
|
||||||
orderAction := "open_long"
|
orderAction := "open_long"
|
||||||
side := strings.ToLower(fill.Side)
|
side := strings.ToLower(fill.Side)
|
||||||
tradeSide := strings.ToLower(fill.TradeSide)
|
tradeSide := strings.ToLower(fill.TradeSide)
|
||||||
|
|
||||||
if tradeSide == "open" {
|
// One-way position mode (buy_single/sell_single)
|
||||||
|
if tradeSide == "buy_single" {
|
||||||
|
orderAction = "open_long"
|
||||||
|
} else if tradeSide == "sell_single" {
|
||||||
|
orderAction = "close_long"
|
||||||
|
} else if tradeSide == "open" {
|
||||||
|
// Hedge mode: open
|
||||||
if side == "buy" {
|
if side == "buy" {
|
||||||
orderAction = "open_long"
|
orderAction = "open_long"
|
||||||
} else {
|
} else {
|
||||||
orderAction = "open_short"
|
orderAction = "open_short"
|
||||||
}
|
}
|
||||||
} else if tradeSide == "close" {
|
} else if tradeSide == "close" {
|
||||||
|
// Hedge mode: close
|
||||||
if side == "sell" {
|
if side == "sell" {
|
||||||
orderAction = "close_long"
|
orderAction = "close_long"
|
||||||
} else {
|
} else {
|
||||||
@@ -108,8 +138,8 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
|||||||
Side: fill.Side,
|
Side: fill.Side,
|
||||||
FillPrice: fillPrice,
|
FillPrice: fillPrice,
|
||||||
FillQty: fillQty,
|
FillQty: fillQty,
|
||||||
Fee: -fee, // Bitget returns negative fee
|
Fee: -fee, // Bitget returns negative fee, convert to positive
|
||||||
FeeAsset: fill.FeeCcy,
|
FeeAsset: feeAsset,
|
||||||
ExecTime: time.UnixMilli(cTime).UTC(),
|
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||||
ProfitLoss: profit,
|
ProfitLoss: profit,
|
||||||
OrderType: "MARKET",
|
OrderType: "MARKET",
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package bitget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Bitget API endpoints (V2)
|
// Bitget API endpoints (V2)
|
||||||
@@ -1013,7 +1014,7 @@ func (t *BitgetTrader) GetOrderStatus(symbol string, orderID string) (map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetClosedPnL retrieves closed position PnL records
|
// GetClosedPnL retrieves closed position PnL records
|
||||||
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
@@ -1051,9 +1052,9 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
|
|||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
records := make([]ClosedPnLRecord, 0, len(resp.List))
|
records := make([]types.ClosedPnLRecord, 0, len(resp.List))
|
||||||
for _, pos := range resp.List {
|
for _, pos := range resp.List {
|
||||||
record := ClosedPnLRecord{
|
record := types.ClosedPnLRecord{
|
||||||
Symbol: pos.Symbol,
|
Symbol: pos.Symbol,
|
||||||
Side: pos.HoldSide,
|
Side: pos.HoldSide,
|
||||||
}
|
}
|
||||||
@@ -1098,9 +1099,9 @@ func genBitgetClientOid() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenOrders gets all open/pending orders for a symbol
|
// 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)
|
symbol = t.convertSymbol(symbol)
|
||||||
var result []OpenOrder
|
var result []types.OpenOrder
|
||||||
|
|
||||||
// 1. Get pending limit orders
|
// 1. Get pending limit orders
|
||||||
params := map[string]interface{}{
|
params := map[string]interface{}{
|
||||||
@@ -1135,7 +1136,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
side := strings.ToUpper(order.Side)
|
side := strings.ToUpper(order.Side)
|
||||||
positionSide := strings.ToUpper(order.PosSide)
|
positionSide := strings.ToUpper(order.PosSide)
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: order.OrderId,
|
OrderID: order.OrderId,
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
@@ -1151,9 +1152,10 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
// 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{}{
|
planParams := map[string]interface{}{
|
||||||
"symbol": symbol,
|
|
||||||
"productType": "USDT-FUTURES",
|
"productType": "USDT-FUTURES",
|
||||||
|
"planType": "profit_loss",
|
||||||
}
|
}
|
||||||
|
|
||||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||||
@@ -1163,33 +1165,53 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
if err == nil && planData != nil {
|
if err == nil && planData != nil {
|
||||||
var planOrders struct {
|
var planOrders struct {
|
||||||
EntrustedList []struct {
|
EntrustedList []struct {
|
||||||
OrderId string `json:"orderId"`
|
OrderId string `json:"orderId"`
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Side string `json:"side"`
|
Side string `json:"side"`
|
||||||
PosSide string `json:"posSide"`
|
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"`
|
TriggerPrice string `json:"triggerPrice"`
|
||||||
Size string `json:"size"`
|
StopLossTriggerPrice string `json:"stopLossTriggerPrice"`
|
||||||
State string `json:"state"`
|
StopSurplusTriggerPrice string `json:"stopSurplusTriggerPrice"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
PlanStatus string `json:"planStatus"`
|
||||||
} `json:"entrustedList"`
|
} `json:"entrustedList"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||||
for _, order := range planOrders.EntrustedList {
|
for _, order := range planOrders.EntrustedList {
|
||||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
// Filter by symbol if specified
|
||||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
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)
|
side := strings.ToUpper(order.Side)
|
||||||
positionSide := strings.ToUpper(order.PosSide)
|
positionSide := strings.ToUpper(order.PosSide)
|
||||||
|
|
||||||
// Map Bitget plan type to order type
|
result = append(result, types.OpenOrder{
|
||||||
orderType := "STOP_MARKET"
|
|
||||||
if order.PlanType == "profit_plan" {
|
|
||||||
orderType = "TAKE_PROFIT_MARKET"
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
|
||||||
OrderID: order.OrderId,
|
OrderID: order.OrderId,
|
||||||
Symbol: symbol,
|
Symbol: order.Symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
PositionSide: positionSide,
|
PositionSide: positionSide,
|
||||||
Type: orderType,
|
Type: orderType,
|
||||||
@@ -1208,7 +1230,7 @@ func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
|
|
||||||
// PlaceLimitOrder places a limit order for grid trading
|
// PlaceLimitOrder places a limit order for grid trading
|
||||||
// Implements GridTrader interface
|
// 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)
|
symbol := t.convertSymbol(req.Symbol)
|
||||||
|
|
||||||
// Set leverage if specified
|
// 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",
|
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||||
symbol, side, req.Price, order.OrderId)
|
symbol, side, req.Price, order.OrderId)
|
||||||
|
|
||||||
return &LimitOrderResult{
|
return &types.LimitOrderResult{
|
||||||
OrderID: order.OrderId,
|
OrderID: order.OrderId,
|
||||||
ClientID: order.ClientOid,
|
ClientID: order.ClientOid,
|
||||||
Symbol: req.Symbol,
|
Symbol: req.Symbol,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package bybit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package bybit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
bybit "github.com/bybit-exchange/bybit.go.api"
|
bybit "github.com/bybit-exchange/bybit.go.api"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BybitTrader Bybit USDT Perpetual Futures Trader
|
// BybitTrader Bybit USDT Perpetual Futures Trader
|
||||||
@@ -900,13 +901,13 @@ func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
|
// GetClosedPnL retrieves closed position PnL records from Bybit via direct HTTP API
|
||||||
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *BybitTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
|
// The Bybit SDK doesn't expose the closed-pnl endpoint, use direct HTTP call
|
||||||
return t.getClosedPnLViaHTTP(startTime, limit)
|
return t.getClosedPnLViaHTTP(startTime, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
|
// getClosedPnLViaHTTP makes direct HTTP call to Bybit API for closed PnL with proper signing
|
||||||
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
// Build query string
|
// Build query string
|
||||||
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
queryParams := fmt.Sprintf("category=linear&startTime=%d&limit=%d", startTime.UnixMilli(), limit)
|
||||||
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
|
url := "https://api.bybit.com/v5/position/closed-pnl?" + queryParams
|
||||||
@@ -967,14 +968,14 @@ func (t *BybitTrader) getClosedPnLViaHTTP(startTime time.Time, limit int) ([]Clo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
// parseClosedPnLResult parses the closed PnL result from Bybit API
|
||||||
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLRecord, error) {
|
func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]types.ClosedPnLRecord, error) {
|
||||||
data, ok := resultData.(map[string]interface{})
|
data, ok := resultData.(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid result format")
|
return nil, fmt.Errorf("invalid result format")
|
||||||
}
|
}
|
||||||
|
|
||||||
list, _ := data["list"].([]interface{})
|
list, _ := data["list"].([]interface{})
|
||||||
var records []ClosedPnLRecord
|
var records []types.ClosedPnLRecord
|
||||||
|
|
||||||
for _, item := range list {
|
for _, item := range list {
|
||||||
pnl, ok := item.(map[string]interface{})
|
pnl, ok := item.(map[string]interface{})
|
||||||
@@ -1023,7 +1024,7 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
|||||||
normalizedSide = "short"
|
normalizedSide = "short"
|
||||||
}
|
}
|
||||||
|
|
||||||
record := ClosedPnLRecord{
|
record := types.ClosedPnLRecord{
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Side: normalizedSide,
|
Side: normalizedSide,
|
||||||
EntryPrice: avgEntryPrice,
|
EntryPrice: avgEntryPrice,
|
||||||
@@ -1046,8 +1047,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenOrders gets all open/pending orders for a symbol
|
// GetOpenOrders gets all open/pending orders for a symbol
|
||||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
func (t *BybitTrader) GetOpenOrders(symbol string) ([]types.OpenOrder, error) {
|
||||||
var result []OpenOrder
|
var result []types.OpenOrder
|
||||||
|
|
||||||
// Get conditional orders (stop-loss, take-profit)
|
// Get conditional orders (stop-loss, take-profit)
|
||||||
params := map[string]interface{}{
|
params := map[string]interface{}{
|
||||||
@@ -1088,7 +1089,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
displayType = stopOrderType
|
displayType = stopOrderType
|
||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: orderId,
|
OrderID: orderId,
|
||||||
Symbol: sym,
|
Symbol: sym,
|
||||||
Side: side,
|
Side: side,
|
||||||
@@ -1108,7 +1109,7 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
|
|
||||||
// PlaceLimitOrder places a limit order for grid trading
|
// PlaceLimitOrder places a limit order for grid trading
|
||||||
// Implements GridTrader interface
|
// Implements GridTrader interface
|
||||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
func (t *BybitTrader) PlaceLimitOrder(req *types.LimitOrderRequest) (*types.LimitOrderResult, error) {
|
||||||
// Format quantity
|
// Format quantity
|
||||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||||
if err != nil {
|
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",
|
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||||
|
|
||||||
return &LimitOrderResult{
|
return &types.LimitOrderResult{
|
||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
ClientID: req.ClientID,
|
ClientID: req.ClientID,
|
||||||
Symbol: req.Symbol,
|
Symbol: req.Symbol,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package bybit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"nofx/trader/testutil"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -18,8 +20,8 @@ import (
|
|||||||
// BybitTraderTestSuite Bybit trader test suite
|
// BybitTraderTestSuite Bybit trader test suite
|
||||||
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
// Inherits TraderTestSuite and adds Bybit-specific mock logic
|
||||||
type BybitTraderTestSuite struct {
|
type BybitTraderTestSuite struct {
|
||||||
*TraderTestSuite // Embeds base test suite
|
*testutil.TraderTestSuite // Embeds base test suite
|
||||||
mockServer *httptest.Server
|
mockServer *httptest.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBybitTraderTestSuite Create Bybit test suite
|
// NewBybitTraderTestSuite Create Bybit test suite
|
||||||
@@ -66,10 +68,10 @@ func NewBybitTraderTestSuite(t *testing.T) *BybitTraderTestSuite {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Create real Bybit trader (for interface compliance testing)
|
// Create real Bybit trader (for interface compliance testing)
|
||||||
trader := NewBybitTrader("test_api_key", "test_secret_key")
|
traderInstance := NewBybitTrader("test_api_key", "test_secret_key")
|
||||||
|
|
||||||
// Create base suite
|
// Create base suite
|
||||||
baseSuite := NewTraderTestSuite(t, trader)
|
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||||
|
|
||||||
return &BybitTraderTestSuite{
|
return &BybitTraderTestSuite{
|
||||||
TraderTestSuite: baseSuite,
|
TraderTestSuite: baseSuite,
|
||||||
@@ -91,7 +93,7 @@ func (s *BybitTraderTestSuite) Cleanup() {
|
|||||||
|
|
||||||
// TestBybitTrader_InterfaceCompliance Test interface compliance
|
// TestBybitTrader_InterfaceCompliance Test interface compliance
|
||||||
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
|
func TestBybitTrader_InterfaceCompliance(t *testing.T) {
|
||||||
var _ Trader = (*BybitTrader)(nil)
|
var _ types.Trader = (*BybitTrader)(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -128,13 +130,13 @@ func TestNewBybitTrader(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
trader := NewBybitTrader(tt.apiKey, tt.secretKey)
|
bt := NewBybitTrader(tt.apiKey, tt.secretKey)
|
||||||
|
|
||||||
if tt.wantNil {
|
if tt.wantNil {
|
||||||
assert.Nil(t, trader)
|
assert.Nil(t, bt)
|
||||||
} else {
|
} else {
|
||||||
assert.NotNil(t, trader)
|
assert.NotNil(t, bt)
|
||||||
assert.NotNil(t, trader.client)
|
assert.NotNil(t, bt.client)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ func TestBybitTrader_SymbolFormat(t *testing.T) {
|
|||||||
|
|
||||||
// TestBybitTrader_FormatQuantity Test quantity formatting
|
// TestBybitTrader_FormatQuantity Test quantity formatting
|
||||||
func TestBybitTrader_FormatQuantity(t *testing.T) {
|
func TestBybitTrader_FormatQuantity(t *testing.T) {
|
||||||
trader := NewBybitTrader("test", "test")
|
bt := NewBybitTrader("test", "test")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -210,7 +212,7 @@ func TestBybitTrader_FormatQuantity(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result, err := trader.FormatQuantity(tt.symbol, tt.quantity)
|
result, err := bt.FormatQuantity(tt.symbol, tt.quantity)
|
||||||
if tt.hasError {
|
if tt.hasError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -335,19 +337,19 @@ func convertBybitSide(side string) string {
|
|||||||
// TestBybitTrader_CategoryLinear Test using only linear category
|
// TestBybitTrader_CategoryLinear Test using only linear category
|
||||||
func TestBybitTrader_CategoryLinear(t *testing.T) {
|
func TestBybitTrader_CategoryLinear(t *testing.T) {
|
||||||
// Bybit trader should only use linear category (USDT perpetual contracts)
|
// Bybit trader should only use linear category (USDT perpetual contracts)
|
||||||
trader := NewBybitTrader("test", "test")
|
bt := NewBybitTrader("test", "test")
|
||||||
assert.NotNil(t, trader)
|
assert.NotNil(t, bt)
|
||||||
|
|
||||||
// Verify default configuration
|
// Verify default configuration
|
||||||
assert.NotNil(t, trader.client)
|
assert.NotNil(t, bt.client)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBybitTrader_CacheDuration Test cache duration
|
// TestBybitTrader_CacheDuration Test cache duration
|
||||||
func TestBybitTrader_CacheDuration(t *testing.T) {
|
func TestBybitTrader_CacheDuration(t *testing.T) {
|
||||||
trader := NewBybitTrader("test", "test")
|
bt := NewBybitTrader("test", "test")
|
||||||
|
|
||||||
// Verify default cache time is 15 seconds
|
// Verify default cache time is 15 seconds
|
||||||
assert.Equal(t, 15*time.Second, trader.cacheDuration)
|
assert.Equal(t, 15*time.Second, bt.cacheDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
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
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -16,16 +16,18 @@ import (
|
|||||||
|
|
||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/sonirico/go-hyperliquid"
|
"github.com/sonirico/go-hyperliquid"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HyperliquidTrader Hyperliquid trader
|
// HyperliquidTrader Hyperliquid trader
|
||||||
type HyperliquidTrader struct {
|
type HyperliquidTrader struct {
|
||||||
exchange *hyperliquid.Exchange
|
exchange *hyperliquid.Exchange
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
walletAddr string
|
walletAddr string
|
||||||
meta *hyperliquid.Meta // Cache meta information (including precision)
|
meta *hyperliquid.Meta // Cache meta information (including precision)
|
||||||
metaMutex sync.RWMutex // Protect concurrent access to meta field
|
metaMutex sync.RWMutex // Protect concurrent access to meta field
|
||||||
isCrossMargin bool // Whether to use cross margin mode
|
isCrossMargin bool // Whether to use cross margin mode
|
||||||
|
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
|
||||||
// xyz dex support (stocks, forex, commodities)
|
// xyz dex support (stocks, forex, commodities)
|
||||||
xyzMeta *xyzDexMeta
|
xyzMeta *xyzDexMeta
|
||||||
xyzMetaMutex sync.RWMutex
|
xyzMetaMutex sync.RWMutex
|
||||||
@@ -79,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewHyperliquidTrader creates a Hyperliquid trader
|
// NewHyperliquidTrader creates a Hyperliquid trader
|
||||||
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
|
// unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
|
||||||
|
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
|
||||||
// Remove 0x prefix from private key (if present, case-insensitive)
|
// Remove 0x prefix from private key (if present, case-insensitive)
|
||||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||||
|
|
||||||
@@ -174,14 +177,19 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if unifiedAccount {
|
||||||
|
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
|
||||||
|
}
|
||||||
|
|
||||||
return &HyperliquidTrader{
|
return &HyperliquidTrader{
|
||||||
exchange: exchange,
|
exchange: exchange,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
walletAddr: walletAddr,
|
walletAddr: walletAddr,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
isCrossMargin: true, // Use cross margin mode by default
|
isCrossMargin: true, // Use cross margin mode by default
|
||||||
privateKey: privateKey,
|
isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
|
||||||
isTestnet: testnet,
|
privateKey: privateKey,
|
||||||
|
isTestnet: testnet,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +257,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
|||||||
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
|
// AccountValue = Total account equity (includes idle funds + position value + unrealized PnL)
|
||||||
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
|
// TotalMarginUsed = Margin used by positions (included in AccountValue, for display only)
|
||||||
//
|
//
|
||||||
// To be compatible with auto_trader.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
// To be compatible with auto_types.go calculation logic (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||||
// Need to return "wallet balance without unrealized PnL"
|
// Need to return "wallet balance without unrealized PnL"
|
||||||
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
|
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
|
||||||
|
|
||||||
@@ -303,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
|||||||
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
|
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
|
||||||
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
|
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
|
||||||
|
|
||||||
|
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
|
||||||
|
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
|
||||||
|
if t.isUnifiedAccount && spotUSDCBalance > 0 {
|
||||||
|
// Add Spot balance to available balance for trading
|
||||||
|
availableBalance = availableBalance + spotUSDCBalance
|
||||||
|
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
|
||||||
|
spotUSDCBalance, availableBalance)
|
||||||
|
}
|
||||||
|
|
||||||
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
|
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
|
||||||
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
|
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
|
||||||
result["availableBalance"] = availableBalance // Available balance (Perpetuals only)
|
result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
|
||||||
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
|
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
|
||||||
result["spotBalance"] = spotUSDCBalance // Spot balance
|
result["spotBalance"] = spotUSDCBalance // Spot balance
|
||||||
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
|
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
|
||||||
@@ -1950,14 +1967,14 @@ func absFloat(x float64) float64 {
|
|||||||
// GetClosedPnL gets recent closing trades from Hyperliquid
|
// GetClosedPnL gets recent closing trades from Hyperliquid
|
||||||
// Note: Hyperliquid does NOT have a position history API, only fill history.
|
// Note: Hyperliquid does NOT have a position history API, only fill history.
|
||||||
// This returns individual closing trades for real-time position closure detection.
|
// This returns individual closing trades for real-time position closure detection.
|
||||||
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]types.ClosedPnLRecord, error) {
|
||||||
trades, err := t.GetTrades(startTime, limit)
|
trades, err := t.GetTrades(startTime, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter only closing trades (realizedPnl != 0)
|
// Filter only closing trades (realizedPnl != 0)
|
||||||
var records []ClosedPnLRecord
|
var records []types.ClosedPnLRecord
|
||||||
for _, trade := range trades {
|
for _, trade := range trades {
|
||||||
if trade.RealizedPnL == 0 {
|
if trade.RealizedPnL == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -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,
|
Symbol: trade.Symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
EntryPrice: entryPrice,
|
EntryPrice: entryPrice,
|
||||||
@@ -2001,7 +2018,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTrades retrieves trade history from Hyperliquid
|
// GetTrades retrieves trade history from Hyperliquid
|
||||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||||
// Use UserFillsByTime API
|
// Use UserFillsByTime API
|
||||||
startTimeMs := startTime.UnixMilli()
|
startTimeMs := startTime.UnixMilli()
|
||||||
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
||||||
@@ -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)
|
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var trades []TradeRecord
|
var trades []types.TradeRecord
|
||||||
for _, fill := range fills {
|
for _, fill := range fills {
|
||||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||||
@@ -2054,7 +2071,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
|
// Hyperliquid uses one-way mode, so PositionSide is "BOTH"
|
||||||
trade := TradeRecord{
|
trade := types.TradeRecord{
|
||||||
TradeID: strconv.FormatInt(fill.Tid, 10),
|
TradeID: strconv.FormatInt(fill.Tid, 10),
|
||||||
Symbol: fill.Coin,
|
Symbol: fill.Coin,
|
||||||
Side: side,
|
Side: side,
|
||||||
@@ -2082,13 +2099,13 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
|||||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||||
|
|
||||||
// GetOpenOrders gets all open/pending orders for a symbol
|
// 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)
|
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []OpenOrder
|
var result []types.OpenOrder
|
||||||
for _, order := range openOrders {
|
for _, order := range openOrders {
|
||||||
if order.Coin != symbol {
|
if order.Coin != symbol {
|
||||||
continue
|
continue
|
||||||
@@ -2099,7 +2116,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
side = "SELL"
|
side = "SELL"
|
||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, OpenOrder{
|
result = append(result, types.OpenOrder{
|
||||||
OrderID: fmt.Sprintf("%d", order.Oid),
|
OrderID: fmt.Sprintf("%d", order.Oid),
|
||||||
Symbol: order.Coin,
|
Symbol: order.Coin,
|
||||||
Side: side,
|
Side: side,
|
||||||
@@ -2117,7 +2134,7 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
|||||||
|
|
||||||
// PlaceLimitOrder places a limit order for grid trading
|
// PlaceLimitOrder places a limit order for grid trading
|
||||||
// Implements GridTrader interface
|
// 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)
|
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||||
|
|
||||||
// Set leverage if specified and not xyz dex
|
// 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",
|
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||||
coin, req.Side, roundedPrice)
|
coin, req.Side, roundedPrice)
|
||||||
|
|
||||||
return &LimitOrderResult{
|
return &types.LimitOrderResult{
|
||||||
OrderID: orderID,
|
OrderID: orderID,
|
||||||
ClientID: req.ClientID,
|
ClientID: req.ClientID,
|
||||||
Symbol: req.Symbol,
|
Symbol: req.Symbol,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// TestMetaConcurrentAccess tests that concurrent access to meta field is safe
|
// TestMetaConcurrentAccess tests that concurrent access to meta field is safe
|
||||||
func TestMetaConcurrentAccess(t *testing.T) {
|
func TestMetaConcurrentAccess(t *testing.T) {
|
||||||
// Create a HyperliquidTrader instance with meta initialized
|
// Create a HyperliquidTrader instance with meta initialized
|
||||||
trader := &HyperliquidTrader{
|
ht := &HyperliquidTrader{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
meta: &hyperliquid.Meta{
|
meta: &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
@@ -32,7 +32,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
// This should not cause race conditions
|
// This should not cause race conditions
|
||||||
decimals := trader.getSzDecimals("BTC")
|
decimals := ht.getSzDecimals("BTC")
|
||||||
if decimals != 5 {
|
if decimals != 5 {
|
||||||
t.Errorf("Expected decimals 5, got %d", decimals)
|
t.Errorf("Expected decimals 5, got %d", decimals)
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ func TestMetaConcurrentAccess(t *testing.T) {
|
|||||||
|
|
||||||
// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field
|
// TestMetaConcurrentReadWrite tests concurrent reads and writes to meta field
|
||||||
func TestMetaConcurrentReadWrite(t *testing.T) {
|
func TestMetaConcurrentReadWrite(t *testing.T) {
|
||||||
trader := &HyperliquidTrader{
|
ht := &HyperliquidTrader{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
meta: &hyperliquid.Meta{
|
meta: &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
@@ -62,7 +62,7 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
trader.getSzDecimals("BTC")
|
ht.getSzDecimals("BTC")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,36 +72,36 @@ func TestMetaConcurrentReadWrite(t *testing.T) {
|
|||||||
go func(iteration int) {
|
go func(iteration int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
// Simulate meta update
|
// Simulate meta update
|
||||||
trader.metaMutex.Lock()
|
ht.metaMutex.Lock()
|
||||||
trader.meta = &hyperliquid.Meta{
|
ht.meta = &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
{Name: "BTC", SzDecimals: 5 + iteration%3},
|
{Name: "BTC", SzDecimals: 5 + iteration%3},
|
||||||
{Name: "ETH", SzDecimals: 4},
|
{Name: "ETH", SzDecimals: 4},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
trader.metaMutex.Unlock()
|
ht.metaMutex.Unlock()
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Verify meta is not nil after all operations
|
// Verify meta is not nil after all operations
|
||||||
trader.metaMutex.RLock()
|
ht.metaMutex.RLock()
|
||||||
if trader.meta == nil {
|
if ht.meta == nil {
|
||||||
t.Error("Meta should not be nil after concurrent operations")
|
t.Error("Meta should not be nil after concurrent operations")
|
||||||
}
|
}
|
||||||
trader.metaMutex.RUnlock()
|
ht.metaMutex.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
|
// TestGetSzDecimals_NilMeta tests getSzDecimals with nil meta
|
||||||
func TestGetSzDecimals_NilMeta(t *testing.T) {
|
func TestGetSzDecimals_NilMeta(t *testing.T) {
|
||||||
trader := &HyperliquidTrader{
|
ht := &HyperliquidTrader{
|
||||||
meta: nil,
|
meta: nil,
|
||||||
metaMutex: sync.RWMutex{},
|
metaMutex: sync.RWMutex{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should return default value 4 when meta is nil
|
// Should return default value 4 when meta is nil
|
||||||
decimals := trader.getSzDecimals("BTC")
|
decimals := ht.getSzDecimals("BTC")
|
||||||
expectedDecimals := 4
|
expectedDecimals := 4
|
||||||
|
|
||||||
if decimals != expectedDecimals {
|
if decimals != expectedDecimals {
|
||||||
@@ -111,7 +111,7 @@ func TestGetSzDecimals_NilMeta(t *testing.T) {
|
|||||||
|
|
||||||
// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta
|
// TestGetSzDecimals_ValidMeta tests getSzDecimals with valid meta
|
||||||
func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
||||||
trader := &HyperliquidTrader{
|
ht := &HyperliquidTrader{
|
||||||
meta: &hyperliquid.Meta{
|
meta: &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
{Name: "BTC", SzDecimals: 5},
|
{Name: "BTC", SzDecimals: 5},
|
||||||
@@ -133,7 +133,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.coin, func(t *testing.T) {
|
t.Run(tt.coin, func(t *testing.T) {
|
||||||
decimals := trader.getSzDecimals(tt.coin)
|
decimals := ht.getSzDecimals(tt.coin)
|
||||||
if decimals != tt.expectedDecimals {
|
if decimals != tt.expectedDecimals {
|
||||||
t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals)
|
t.Errorf("For coin %s, expected decimals %d, got %d", tt.coin, tt.expectedDecimals, decimals)
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ func TestGetSzDecimals_ValidMeta(t *testing.T) {
|
|||||||
// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues
|
// TestMetaMutex_NoRaceCondition tests that using -race detector finds no issues
|
||||||
// Run with: go test -race -run TestMetaMutex_NoRaceCondition
|
// Run with: go test -race -run TestMetaMutex_NoRaceCondition
|
||||||
func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
||||||
trader := &HyperliquidTrader{
|
ht := &HyperliquidTrader{
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
meta: &hyperliquid.Meta{
|
meta: &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
@@ -163,8 +163,8 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
trader.getSzDecimals("BTC")
|
ht.getSzDecimals("BTC")
|
||||||
trader.getSzDecimals("ETH")
|
ht.getSzDecimals("ETH")
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +173,15 @@ func TestMetaMutex_NoRaceCondition(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
trader.metaMutex.Lock()
|
ht.metaMutex.Lock()
|
||||||
trader.meta = &hyperliquid.Meta{
|
ht.meta = &hyperliquid.Meta{
|
||||||
Universe: []hyperliquid.AssetInfo{
|
Universe: []hyperliquid.AssetInfo{
|
||||||
{Name: "BTC", SzDecimals: 5},
|
{Name: "BTC", SzDecimals: 5},
|
||||||
{Name: "ETH", SzDecimals: 4},
|
{Name: "ETH", SzDecimals: 4},
|
||||||
{Name: "SOL", SzDecimals: 3},
|
{Name: "SOL", SzDecimals: 3},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
trader.metaMutex.Unlock()
|
ht.metaMutex.Unlock()
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/crypto"
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
"github.com/sonirico/go-hyperliquid"
|
"github.com/sonirico/go-hyperliquid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"nofx/trader/testutil"
|
||||||
|
"nofx/trader/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -20,9 +22,9 @@ import (
|
|||||||
// HyperliquidTestSuite Hyperliquid trader test suite
|
// HyperliquidTestSuite Hyperliquid trader test suite
|
||||||
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
// Inherits TraderTestSuite and adds Hyperliquid-specific mock logic
|
||||||
type HyperliquidTestSuite struct {
|
type HyperliquidTestSuite struct {
|
||||||
*TraderTestSuite // Embeds base test suite
|
*testutil.TraderTestSuite // Embeds base test suite
|
||||||
mockServer *httptest.Server
|
mockServer *httptest.Server
|
||||||
privateKey *ecdsa.PrivateKey
|
privateKey *ecdsa.PrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHyperliquidTestSuite Create Hyperliquid test suite
|
// NewHyperliquidTestSuite Create Hyperliquid test suite
|
||||||
@@ -216,7 +218,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
trader := &HyperliquidTrader{
|
traderInstance := &HyperliquidTrader{
|
||||||
exchange: exchange,
|
exchange: exchange,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
walletAddr: walletAddr,
|
walletAddr: walletAddr,
|
||||||
@@ -225,7 +227,7 @@ func NewHyperliquidTestSuite(t *testing.T) *HyperliquidTestSuite {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create base suite
|
// Create base suite
|
||||||
baseSuite := NewTraderTestSuite(t, trader)
|
baseSuite := testutil.NewTraderTestSuite(t, traderInstance)
|
||||||
|
|
||||||
return &HyperliquidTestSuite{
|
return &HyperliquidTestSuite{
|
||||||
TraderTestSuite: baseSuite,
|
TraderTestSuite: baseSuite,
|
||||||
@@ -248,7 +250,7 @@ func (s *HyperliquidTestSuite) Cleanup() {
|
|||||||
|
|
||||||
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
|
// TestHyperliquidTrader_InterfaceCompliance Test interface compliance
|
||||||
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
func TestHyperliquidTrader_InterfaceCompliance(t *testing.T) {
|
||||||
var _ Trader = (*HyperliquidTrader)(nil)
|
var _ types.Trader = (*HyperliquidTrader)(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
|
// TestHyperliquidTrader_CommonInterface Run all common interface tests using test suite
|
||||||
@@ -562,8 +564,8 @@ func TestHyperliquidTrader_GetSzDecimals(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
trader := &HyperliquidTrader{meta: tt.meta}
|
ht := &HyperliquidTrader{meta: tt.meta}
|
||||||
result := trader.getSzDecimals(tt.coin)
|
result := ht.getSzDecimals(tt.coin)
|
||||||
assert.Equal(t, tt.expected, result)
|
assert.Equal(t, tt.expected, result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package trader
|
package hyperliquid
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"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