mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-24 12:32:51 +08:00
Compare commits
133 Commits
feat/add-q
...
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 | ||
|
|
5ea9a3990e | ||
|
|
5b5199359c | ||
|
|
9dbc861cdf | ||
|
|
fcc0267a46 | ||
|
|
c9150e8273 | ||
|
|
fcaabea6cb | ||
|
|
b5716ff3cb | ||
|
|
2f54d1d4c0 | ||
|
|
0b448558ca | ||
|
|
84276f64ae | ||
|
|
5560df133e | ||
|
|
f43c63699b | ||
|
|
7b1edaa51f | ||
|
|
ed8ad63288 | ||
|
|
a7370efc2f | ||
|
|
5b384d126f | ||
|
|
1532b55d77 | ||
|
|
0e75b80d95 | ||
|
|
9c57134dfb | ||
|
|
7ce7361cef | ||
|
|
7a1643c56c | ||
|
|
7e96c5d0f2 | ||
|
|
aa6168afe3 | ||
|
|
917a16381f | ||
|
|
7db84d57d3 | ||
|
|
95486173f7 | ||
|
|
ee081ebc85 | ||
|
|
502801777f | ||
|
|
b10b9ec1a7 | ||
|
|
c1def0e2c2 | ||
|
|
705aa641b0 | ||
|
|
2f88205231 | ||
|
|
e92222950a | ||
|
|
138943d6fb | ||
|
|
b36ab27b65 | ||
|
|
5e65ae7077 | ||
|
|
c0c89d7534 | ||
|
|
3b2a3f4e76 | ||
|
|
c8458ec79c | ||
|
|
aee096ab1e | ||
|
|
165c0b1b5d | ||
|
|
4c097f7190 | ||
|
|
ea763a2471 | ||
|
|
6e6bdf1e57 | ||
|
|
f0b4913ad6 | ||
|
|
29cd79c626 | ||
|
|
7db37ade1c | ||
|
|
4804cfcb05 | ||
|
|
799d8b9c2e | ||
|
|
5c4c9cdc99 | ||
|
|
8b86d4d85c | ||
|
|
962df5c3ed | ||
|
|
9f3de6e3c0 | ||
|
|
5c9e134e99 | ||
|
|
50923f6a2e | ||
|
|
bdfd8dc0d0 | ||
|
|
0275e23b7e | ||
|
|
13fda47151 | ||
|
|
d664dcca3d | ||
|
|
04141642a5 | ||
|
|
7f7c4ea2a7 | ||
|
|
e07dc0de86 | ||
|
|
cc726adb57 | ||
|
|
7df8197542 | ||
|
|
60194306e1 |
@@ -52,10 +52,6 @@ TRANSPORT_ENCRYPTION=false
|
||||
# Optional: External Services
|
||||
# ===========================================
|
||||
|
||||
# Telegram notifications (optional)
|
||||
# TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
# TELEGRAM_CHAT_ID=your-chat-id
|
||||
|
||||
DB_TYPE=postgres
|
||||
DB_HOST=10.
|
||||
DB_PORT=5432
|
||||
|
||||
242
.github/PR_TITLE_GUIDE.md
vendored
242
.github/PR_TITLE_GUIDE.md
vendored
@@ -1,16 +1,16 @@
|
||||
# PR 标题指南
|
||||
# PR Title Guide
|
||||
|
||||
## 📋 概述
|
||||
## 📋 Overview
|
||||
|
||||
我们使用 **Conventional Commits** 格式来保持 PR 标题的一致性,但这是**建议性的**,不会阻止你的 PR 被合并。
|
||||
We use the **Conventional Commits** format to maintain consistency in PR titles, but this is **recommended**, not mandatory. It will not prevent your PR from being merged.
|
||||
|
||||
## ✅ 推荐格式
|
||||
## ✅ Recommended Format
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
### 示例
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(trader): add new trading strategy
|
||||
@@ -22,63 +22,63 @@ ci(workflow): improve GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
## 📖 详细说明
|
||||
## 📖 Detailed Guide
|
||||
|
||||
### Type(类型)- 必需
|
||||
### Type - Required
|
||||
|
||||
描述这次变更的类型:
|
||||
Describes the type of change:
|
||||
|
||||
| Type | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新功能 | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug 修复 | `fix(api): handle null response` |
|
||||
| `docs` | 文档变更 | `docs: update installation guide` |
|
||||
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` |
|
||||
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` |
|
||||
| `test` | 添加或修改测试 | `test(trader): add unit tests` |
|
||||
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` |
|
||||
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` |
|
||||
| `security` | 安全相关修复 | `security: update vulnerable dependencies` |
|
||||
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` |
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `feat` | New feature | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug fix | `fix(api): handle null response` |
|
||||
| `docs` | Documentation change | `docs: update installation guide` |
|
||||
| `style` | Code formatting (no functional change) | `style: format code with prettier` |
|
||||
| `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
|
||||
| `test` | Add or modify tests | `test(trader): add unit tests` |
|
||||
| `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
|
||||
| `ci` | CI/CD related changes | `ci: add test coverage report` |
|
||||
| `security` | Security fixes | `security: update vulnerable dependencies` |
|
||||
| `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
|
||||
|
||||
### Scope(范围)- 可选
|
||||
### Scope - Optional
|
||||
|
||||
描述这次变更影响的范围:
|
||||
Describes the area affected by the change:
|
||||
|
||||
| Scope | 说明 |
|
||||
|-------|------|
|
||||
| `exchange` | 交易所相关 |
|
||||
| `trader` | 交易员/交易策略 |
|
||||
| `ai` | AI 模型相关 |
|
||||
| `api` | API 接口 |
|
||||
| `ui` | 用户界面 |
|
||||
| `frontend` | 前端代码 |
|
||||
| `backend` | 后端代码 |
|
||||
| `security` | 安全相关 |
|
||||
| `deps` | 依赖项 |
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `exchange` | Exchange-related |
|
||||
| `trader` | Trader/trading strategy |
|
||||
| `ai` | AI model related |
|
||||
| `api` | API interface |
|
||||
| `ui` | User interface |
|
||||
| `frontend` | Frontend code |
|
||||
| `backend` | Backend code |
|
||||
| `security` | Security related |
|
||||
| `deps` | Dependencies |
|
||||
| `workflow` | GitHub Actions workflows |
|
||||
| `github` | GitHub 配置 |
|
||||
| `github` | GitHub configuration |
|
||||
| `actions` | GitHub Actions |
|
||||
| `config` | 配置文件 |
|
||||
| `docker` | Docker 相关 |
|
||||
| `build` | 构建相关 |
|
||||
| `release` | 发布相关 |
|
||||
| `config` | Configuration files |
|
||||
| `docker` | Docker related |
|
||||
| `build` | Build related |
|
||||
| `release` | Release related |
|
||||
|
||||
**注意:** 如果变更影响多个范围,可以省略 scope 或选择最主要的。
|
||||
**Note:** If the change affects multiple scopes, you can omit the scope or choose the most relevant one.
|
||||
|
||||
### Description(描述)- 必需
|
||||
### Description - Required
|
||||
|
||||
- 使用现在时态("add" 而不是 "added")
|
||||
- 首字母小写
|
||||
- 结尾不加句号
|
||||
- 简洁明了地描述变更内容
|
||||
- Use present tense ("add" not "added")
|
||||
- Start with lowercase
|
||||
- No period at the end
|
||||
- Concisely describe what changed
|
||||
|
||||
---
|
||||
|
||||
## 🎯 完整示例
|
||||
## 🎯 Complete Examples
|
||||
|
||||
### ✅ 好的 PR 标题
|
||||
### ✅ Good PR Titles
|
||||
|
||||
```
|
||||
feat(trader): add risk management system
|
||||
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
|
||||
build(docker): optimize Docker image size
|
||||
```
|
||||
|
||||
### ⚠️ 需要改进的标题
|
||||
### ⚠️ Titles That Need Improvement
|
||||
|
||||
| 不好的标题 | 问题 | 改进后 |
|
||||
|-----------|------|--------|
|
||||
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | 完全不符合格式 | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` |
|
||||
| Poor Title | Issue | Improved |
|
||||
|-----------|-------|----------|
|
||||
| `update code` | Too vague | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | Doesn't follow format | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | Wrong tense | `feat(trader): add new trading algorithm` |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 自动检查行为
|
||||
## 🤖 Automated Check Behavior
|
||||
|
||||
### 当 PR 标题不符合格式时
|
||||
### When PR Title Doesn't Follow Format
|
||||
|
||||
1. **不会阻止合并** ✅
|
||||
- 检查会标记为"建议"
|
||||
- PR 仍然可以被审查和合并
|
||||
1. **Won't block merging** ✅
|
||||
- Check is marked as "advisory"
|
||||
- PR can still be reviewed and merged
|
||||
|
||||
2. **会收到友好提示** 💬
|
||||
- 机器人会在 PR 中留言
|
||||
- 提供格式说明和示例
|
||||
- 建议如何改进标题
|
||||
2. **Provides friendly reminder** 💬
|
||||
- Bot will comment on the PR
|
||||
- Provides format guidance and examples
|
||||
- Suggests how to improve the title
|
||||
|
||||
3. **可以随时更新** 🔄
|
||||
- 更新 PR 标题后会重新检查
|
||||
- 无需关闭和重新打开 PR
|
||||
3. **Can be updated anytime** 🔄
|
||||
- Re-checks after updating PR title
|
||||
- No need to close and reopen PR
|
||||
|
||||
### 示例评论
|
||||
### Example Comment
|
||||
|
||||
如果你的 PR 标题是 `update workflow`,你会收到这样的评论:
|
||||
If your PR title is `update workflow`, you'll receive a comment like this:
|
||||
|
||||
```markdown
|
||||
## ⚠️ PR Title Format Suggestion
|
||||
@@ -157,11 +157,11 @@ Your PR can still be reviewed and merged.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置详情
|
||||
## 🔧 Configuration Details
|
||||
|
||||
### 支持的 Types
|
||||
### Supported Types
|
||||
|
||||
在 `.github/workflows/pr-checks.yml` 中配置:
|
||||
Configured in `.github/workflows/pr-checks.yml`:
|
||||
|
||||
```yaml
|
||||
types: |
|
||||
@@ -178,7 +178,7 @@ types: |
|
||||
build
|
||||
```
|
||||
|
||||
### 支持的 Scopes
|
||||
### Supported Scopes
|
||||
|
||||
```yaml
|
||||
scopes: |
|
||||
@@ -200,38 +200,38 @@ scopes: |
|
||||
release
|
||||
```
|
||||
|
||||
### 添加新的 Scope
|
||||
### Adding New Scopes
|
||||
|
||||
如果你需要添加新的 scope,请:
|
||||
If you need to add a new scope:
|
||||
|
||||
1. 在 `.github/workflows/pr-checks.yml` 的 `scopes` 部分添加
|
||||
2. 在 `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选)
|
||||
3. 更新本文档
|
||||
1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
|
||||
2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
|
||||
3. Update this documentation
|
||||
|
||||
---
|
||||
|
||||
## 📚 为什么使用 Conventional Commits?
|
||||
## 📚 Why Use Conventional Commits?
|
||||
|
||||
### 优点
|
||||
### Benefits
|
||||
|
||||
1. **自动化 Changelog** 📝
|
||||
- 可以自动生成版本更新日志
|
||||
- 清晰地分类各种变更
|
||||
1. **Automated Changelog** 📝
|
||||
- Automatically generate version changelogs
|
||||
- Clearly categorize different types of changes
|
||||
|
||||
2. **语义化版本** 🔢
|
||||
- `feat` → MINOR 版本(1.1.0)
|
||||
- `fix` → PATCH 版本(1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR 版本(2.0.0)
|
||||
2. **Semantic Versioning** 🔢
|
||||
- `feat` → MINOR version (1.1.0)
|
||||
- `fix` → PATCH version (1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR version (2.0.0)
|
||||
|
||||
3. **更好的可读性** 👀
|
||||
- 一眼看出 PR 的目的
|
||||
- 更容易浏览 Git 历史
|
||||
3. **Better Readability** 👀
|
||||
- Understand PR purpose at a glance
|
||||
- Easier to browse Git history
|
||||
|
||||
4. **团队协作** 🤝
|
||||
- 统一的提交风格
|
||||
- 降低沟通成本
|
||||
4. **Team Collaboration** 🤝
|
||||
- Unified commit style
|
||||
- Reduces communication overhead
|
||||
|
||||
### 示例:自动生成的 Changelog
|
||||
### Example: Auto-generated Changelog
|
||||
|
||||
```markdown
|
||||
## v1.2.0 (2025-11-02)
|
||||
@@ -250,9 +250,9 @@ scopes: |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 学习资源
|
||||
## 🎓 Learning Resources
|
||||
|
||||
- **Conventional Commits 官网:** https://www.conventionalcommits.org/
|
||||
- **Conventional Commits:** https://www.conventionalcommits.org/
|
||||
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
|
||||
- **Semantic Versioning:** https://semver.org/
|
||||
|
||||
@@ -260,33 +260,33 @@ scopes: |
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q: 我必须遵循这个格式吗?
|
||||
### Q: Must I follow this format?
|
||||
|
||||
**A:** 不必须。这是建议性的,不会阻止你的 PR 被合并。但遵循格式可以提高项目的可维护性。
|
||||
**A:** No. This is recommended but not mandatory. It won't block your PR from being merged. However, following the format improves project maintainability.
|
||||
|
||||
### Q: 如果我忘记了怎么办?
|
||||
### Q: What if I forget?
|
||||
|
||||
**A:** 机器人会在 PR 中提醒你,你可以随时更新标题。
|
||||
**A:** The bot will remind you in the PR comments. You can update the title anytime.
|
||||
|
||||
### Q: 我可以在一个 PR 中做多种类型的变更吗?
|
||||
### Q: Can I make multiple types of changes in one PR?
|
||||
|
||||
**A:** 可以,但建议:
|
||||
- 选择最主要的类型
|
||||
- 或者考虑拆分成多个 PR(更易于审查)
|
||||
**A:** Yes, but it's recommended to:
|
||||
- Choose the most significant type
|
||||
- Or consider splitting into multiple PRs (easier to review)
|
||||
|
||||
### Q: Scope 可以省略吗?
|
||||
### Q: Can I omit the scope?
|
||||
|
||||
**A:** 可以。`requireScope: false` 表示 scope 是可选的。
|
||||
**A:** Yes. `requireScope: false` means scope is optional.
|
||||
|
||||
示例:`docs: update README` (没有 scope 也可以)
|
||||
Example: `docs: update README` (no scope is fine)
|
||||
|
||||
### Q: 我想添加新的 type 或 scope,怎么做?
|
||||
### Q: How do I add a new type or scope?
|
||||
|
||||
**A:** 提一个 PR 修改 `.github/workflows/pr-checks.yml`,并在本文档中说明新增项的用途。
|
||||
**A:** Submit a PR to modify `.github/workflows/pr-checks.yml` and document the purpose of the new item in this guide.
|
||||
|
||||
### Q: Breaking Changes 怎么表示?
|
||||
### Q: How do I indicate Breaking Changes?
|
||||
|
||||
**A:** 在描述中添加 `BREAKING CHANGE:` 或在 type 后加 `!`:
|
||||
**A:** Add `BREAKING CHANGE:` in the description or add `!` after the type:
|
||||
|
||||
```
|
||||
feat!: remove deprecated API
|
||||
@@ -297,9 +297,9 @@ BREAKING CHANGE: The old /auth endpoint is removed
|
||||
|
||||
---
|
||||
|
||||
## 📊 统计
|
||||
## 📊 Statistics
|
||||
|
||||
想看项目的 commit 类型分布?运行:
|
||||
Want to see the commit type distribution in your project? Run:
|
||||
|
||||
```bash
|
||||
git log --oneline --no-merges | \
|
||||
@@ -309,14 +309,14 @@ git log --oneline --no-merges | \
|
||||
|
||||
---
|
||||
|
||||
## ✅ 快速检查清单
|
||||
## ✅ Quick Checklist
|
||||
|
||||
在提交 PR 前,检查你的标题是否:
|
||||
Before submitting a PR, check if your title:
|
||||
|
||||
- [ ] 包含有效的 type(feat, fix, docs 等)
|
||||
- [ ] 使用小写字母开头
|
||||
- [ ] 使用现在时态("add" 而不是 "added")
|
||||
- [ ] 简洁明了(最好在 50 字符内)
|
||||
- [ ] 准确描述了变更内容
|
||||
- [ ] Contains a valid type (feat, fix, docs, etc.)
|
||||
- [ ] Starts with lowercase
|
||||
- [ ] Uses present tense ("add" not "added")
|
||||
- [ ] Is concise (preferably under 50 characters)
|
||||
- [ ] Accurately describes the change
|
||||
|
||||
**记住:** 这些都是建议,不是强制要求!
|
||||
**Remember:** These are recommendations, not requirements!
|
||||
|
||||
126
.github/PULL_REQUEST_TEMPLATE.md
vendored
126
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,104 +1,50 @@
|
||||
# Pull Request | PR 提交
|
||||
## Summary
|
||||
|
||||
> **📋 选择专用模板 | Choose Specialized Template**
|
||||
>
|
||||
> 我们现在提供了针对不同类型PR的专用模板,帮助你更快速地填写PR信息:
|
||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||
>
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes
|
||||
>
|
||||
> **如何使用?| How to use?**
|
||||
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - 或者直接复制粘贴对应模板的内容
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
- Problem:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
---
|
||||
## Change Type
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Docs
|
||||
- [ ] Security fix
|
||||
- [ ] Chore / infra
|
||||
|
||||
---
|
||||
## Scope
|
||||
|
||||
## 📝 Description | 描述
|
||||
- [ ] Trading engine / strategies
|
||||
- [ ] MCP / AI clients
|
||||
- [ ] API / server
|
||||
- [ ] Telegram bot / agent
|
||||
- [ ] Web UI / frontend
|
||||
- [ ] Config / deployment
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
**English:** | **中文:**
|
||||
## Linked Issues
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## Testing
|
||||
|
||||
---
|
||||
What you verified and how:
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
- [ ] `go build ./...` passes
|
||||
- [ ] `go test ./...` passes
|
||||
- [ ] Manual testing done (describe below)
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
## Security Impact
|
||||
|
||||
---
|
||||
- Secrets/keys handling changed? (`Yes/No`)
|
||||
- New/changed API endpoints? (`Yes/No`)
|
||||
- User input validation affected? (`Yes/No`)
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## Compatibility
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
|
||||
- [ ] 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 | 贡献遵循 AGPL-3.0 许可证
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, upgrade steps:
|
||||
|
||||
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
@@ -1,213 +1,177 @@
|
||||
# PR Templates | PR 模板
|
||||
# PR Templates
|
||||
|
||||
## 📋 模板概述 | Template Overview
|
||||
## 📋 Template Overview
|
||||
|
||||
我们提供了4种针对不同类型PR的专用模板,帮助贡献者快速填写PR信息:
|
||||
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
|
||||
|
||||
### 1. 🔧 Backend Template | 后端模板
|
||||
**文件:** `backend.md`
|
||||
### 1. 🔧 Backend Template
|
||||
**File:** `backend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- Go代码变更 | Go code changes
|
||||
- API端点开发 | API endpoint development
|
||||
- 交易逻辑实现 | Trading logic implementation
|
||||
- 后端性能优化 | Backend performance optimization
|
||||
- 数据库相关改动 | Database-related changes
|
||||
**Use for:**
|
||||
- Go code changes
|
||||
- API endpoint development
|
||||
- Trading logic implementation
|
||||
- Backend performance optimization
|
||||
- Database-related changes
|
||||
|
||||
**包含 | Includes:**
|
||||
- Go测试环境配置 | Go test environment
|
||||
- 安全考虑检查 | Security considerations
|
||||
- 性能影响评估 | Performance impact assessment
|
||||
- `go fmt` 和 `go build` 检查 | `go fmt` and `go build` checks
|
||||
**Includes:**
|
||||
- Go test environment
|
||||
- Security considerations
|
||||
- Performance impact assessment
|
||||
- `go fmt` and `go build` checks
|
||||
|
||||
### 2. 🎨 Frontend Template | 前端模板
|
||||
**文件:** `frontend.md`
|
||||
### 2. 🎨 Frontend Template
|
||||
**File:** `frontend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- UI/UX变更 | UI/UX changes
|
||||
- React/Vue组件开发 | React/Vue component development
|
||||
- 前端样式更新 | Frontend styling updates
|
||||
- 浏览器兼容性修复 | Browser compatibility fixes
|
||||
- 前端性能优化 | Frontend performance optimization
|
||||
**Use for:**
|
||||
- UI/UX changes
|
||||
- React/Vue component development
|
||||
- Frontend styling updates
|
||||
- Browser compatibility fixes
|
||||
- Frontend performance optimization
|
||||
|
||||
**包含 | Includes:**
|
||||
- 截图/演示要求 | Screenshots/demo requirements
|
||||
- 浏览器测试清单 | Browser testing checklist
|
||||
- 国际化检查 | Internationalization checks
|
||||
- 响应式设计验证 | Responsive design verification
|
||||
- `npm run lint` 和 `npm run build` 检查 | Linting and build checks
|
||||
**Includes:**
|
||||
- Screenshots/demo requirements
|
||||
- Browser testing checklist
|
||||
- Internationalization checks
|
||||
- Responsive design verification
|
||||
- `npm run lint` and `npm run build` checks
|
||||
|
||||
### 3. 📝 Documentation Template | 文档模板
|
||||
**文件:** `docs.md`
|
||||
### 3. 📝 Documentation Template
|
||||
**File:** `docs.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- README更新 | README updates
|
||||
- API文档编写 | API documentation
|
||||
- 教程和指南 | Tutorials and guides
|
||||
- 代码注释改进 | Code comment improvements
|
||||
- 翻译工作 | Translation work
|
||||
**Use for:**
|
||||
- README updates
|
||||
- API documentation
|
||||
- Tutorials and guides
|
||||
- Code comment improvements
|
||||
- Translation work
|
||||
|
||||
**包含 | Includes:**
|
||||
- 文档类型分类 | Documentation type classification
|
||||
- 内容质量检查 | Content quality checks
|
||||
- 双语要求(中英文)| Bilingual requirements (EN/CN)
|
||||
- 链接有效性验证 | Link validity verification
|
||||
**Includes:**
|
||||
- Documentation type classification
|
||||
- Content quality checks
|
||||
- Bilingual requirements (EN/CN)
|
||||
- Link validity verification
|
||||
|
||||
### 4. 📦 General Template | 通用模板
|
||||
**文件:** `general.md`
|
||||
### 4. 📦 General Template
|
||||
**File:** `general.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- 混合类型变更 | Mixed-type changes
|
||||
- 跨多个领域的PR | Cross-domain PRs
|
||||
- 构建配置变更 | Build configuration changes
|
||||
- 依赖更新 | Dependency updates
|
||||
- 不确定使用哪个模板时 | When unsure which template to use
|
||||
**Use for:**
|
||||
- Mixed-type changes
|
||||
- Cross-domain PRs
|
||||
- Build configuration changes
|
||||
- Dependency updates
|
||||
- When unsure which template to use
|
||||
|
||||
## 🤖 自动模板建议 | Automatic Template Suggestion
|
||||
## 🤖 Automatic Template Suggestion
|
||||
|
||||
我们的GitHub Action会自动分析你的PR并建议最合适的模板:
|
||||
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
|
||||
|
||||
### 工作原理 | How it works:
|
||||
### How it works:
|
||||
|
||||
1. **文件分析 | File Analysis**
|
||||
- 检测PR中所有变更的文件类型
|
||||
1. **File Analysis**
|
||||
- Detects all changed file types in the PR
|
||||
|
||||
2. **智能判断 | Smart Detection**
|
||||
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
|
||||
2. **Smart Detection**
|
||||
- If >50% are `.go` files → Suggests **Backend template**
|
||||
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
|
||||
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
||||
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
|
||||
- If >70% are `.md` files → Suggests **Documentation template**
|
||||
|
||||
3. **自动评论 | Auto-comment**
|
||||
- 如果检测到你使用了默认模板,但应该用专用模板
|
||||
3. **Auto-comment**
|
||||
- If it detects you're using the default template but should use a specialized one
|
||||
- 会自动添加友好的评论建议
|
||||
- It will automatically add a friendly comment suggestion
|
||||
|
||||
4. **自动标签 | Auto-labeling**
|
||||
- 自动添加对应的标签:`backend`、`frontend`、`documentation`
|
||||
4. **Auto-labeling**
|
||||
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
|
||||
|
||||
## 📖 使用方法 | How to Use
|
||||
## 📖 How to Use
|
||||
|
||||
### 方法1: URL参数(推荐) | Method 1: URL Parameter (Recommended)
|
||||
### Method 1: URL Parameter (Recommended)
|
||||
|
||||
创建PR时,在URL末尾添加模板参数:
|
||||
When creating a PR, add the template parameter to the URL:
|
||||
|
||||
```
|
||||
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
||||
```
|
||||
|
||||
替换 `backend.md` 为:
|
||||
Replace `backend.md` with:
|
||||
- `backend.md` - 后端模板 | Backend template
|
||||
- `frontend.md` - 前端模板 | Frontend template
|
||||
- `docs.md` - 文档模板 | Documentation template
|
||||
- `general.md` - 通用模板 | General template
|
||||
- `backend.md` - Backend template
|
||||
- `frontend.md` - Frontend template
|
||||
- `docs.md` - Documentation template
|
||||
- `general.md` - General template
|
||||
|
||||
### 方法2: 手动选择 | Method 2: Manual Selection
|
||||
### Method 2: Manual Selection
|
||||
|
||||
1. 创建PR时,默认模板会显示
|
||||
When creating a PR, the default template will be shown
|
||||
1. When creating a PR, the default template will be shown
|
||||
|
||||
2. 根据顶部的指引链接,点击查看对应的模板
|
||||
Follow the guidance links at the top to view the corresponding template
|
||||
2. Follow the guidance links at the top to view the corresponding template
|
||||
|
||||
3. 复制模板内容到PR描述中
|
||||
Copy the template content into the PR description
|
||||
3. Copy the template content into the PR description
|
||||
|
||||
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
|
||||
### Method 3: Follow Auto-suggestion
|
||||
|
||||
1. 使用任何模板创建PR
|
||||
Create a PR with any template
|
||||
1. Create a PR with any template
|
||||
|
||||
2. GitHub Action会自动分析并评论建议
|
||||
GitHub Action will automatically analyze and comment with a suggestion
|
||||
2. GitHub Action will automatically analyze and comment with a suggestion
|
||||
|
||||
3. 根据建议更新PR描述
|
||||
Update the PR description based on the suggestion
|
||||
3. Update the PR description based on the suggestion
|
||||
|
||||
## 🎯 最佳实践 | Best Practices
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **提前选择 | Choose in Advance**
|
||||
- 在创建PR前确定变更类型
|
||||
1. **Choose in Advance**
|
||||
- Determine the change type before creating the PR
|
||||
|
||||
2. **完整填写 | Complete Filling**
|
||||
- 不要跳过必填项(标记为 required)
|
||||
2. **Complete Filling**
|
||||
- Don't skip required items
|
||||
|
||||
3. **保持简洁 | Keep it Concise**
|
||||
- 描述清晰但简洁
|
||||
3. **Keep it Concise**
|
||||
- Keep descriptions clear but concise
|
||||
|
||||
4. **添加截图 | Add Screenshots**
|
||||
- 对于UI变更,务必添加截图
|
||||
4. **Add Screenshots**
|
||||
- For UI changes, always add screenshots
|
||||
|
||||
5. **测试证明 | Test Evidence**
|
||||
- 提供测试通过的证据
|
||||
5. **Test Evidence**
|
||||
- Provide evidence that tests pass
|
||||
|
||||
## 🔧 自定义 | Customization
|
||||
## 🔧 Customization
|
||||
|
||||
如果需要修改模板或自动检测逻辑:
|
||||
If you need to modify templates or auto-detection logic:
|
||||
|
||||
1. **修改模板** | **Modify Templates**
|
||||
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
|
||||
1. **Modify Templates**
|
||||
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
||||
|
||||
2. **调整检测阈值** | **Adjust Detection Threshold**
|
||||
- 编辑 `.github/workflows/pr-template-suggester.yml`
|
||||
2. **Adjust Detection Threshold**
|
||||
- Edit `.github/workflows/pr-template-suggester.yml`
|
||||
- 修改文件类型占比阈值(当前:50%后端,50%前端,70%文档)
|
||||
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
||||
|
||||
3. **添加新模板** | **Add New Template**
|
||||
- 在 `PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
|
||||
3. **Add New Template**
|
||||
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
||||
- 更新工作流以支持新的文件类型检测
|
||||
- Update the workflow to support new file type detection
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
|
||||
**Q: My PR has both frontend and backend code, which template should I use?**
|
||||
|
||||
A: 使用**通用模板**(`general.md`),或选择主要变更类型的模板。
|
||||
A: Use the **General template** (`general.md`), or choose the template for the primary change type.
|
||||
|
||||
---
|
||||
|
||||
**Q: 自动建议的模板不合适怎么办?**
|
||||
**Q: What if the automatically suggested template is not suitable?**
|
||||
|
||||
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
|
||||
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
|
||||
|
||||
---
|
||||
|
||||
**Q: 可以不使用任何模板吗?**
|
||||
**Q: Can I not use any template?**
|
||||
|
||||
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
|
||||
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
||||
|
||||
---
|
||||
|
||||
**Q: 如何禁用自动模板建议?**
|
||||
**Q: How to disable automatic template suggestions?**
|
||||
|
||||
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
|
||||
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
|
||||
|
||||
---
|
||||
|
||||
🌟 **感谢使用我们的PR模板系统!| Thank you for using our PR template system!**
|
||||
🌟 **Thank you for using our PR template system!**
|
||||
|
||||
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
@@ -1,121 +1,116 @@
|
||||
# Pull Request - Backend | 后端 PR
|
||||
# Pull Request - Backend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] 🔒 Security fix
|
||||
- [ ] 🔧 Build/config change
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Go Version | Go 版本:**
|
||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Go Version:**
|
||||
- **Exchange:** [if applicable]
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
||||
- [ ] Unit tests pass | 单元测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested locally
|
||||
- [ ] Tested on testnet (for exchange integration)
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
### Test Results | 测试结果
|
||||
### Test Results
|
||||
```
|
||||
Test output here | 测试输出
|
||||
Test output here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations | 安全考虑
|
||||
## 🔒 Security Considerations
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
|
||||
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
|
||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
||||
- [ ] N/A (not security-related) | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] No SQL injection vulnerabilities
|
||||
- [ ] Authentication/authorization properly handled
|
||||
- [ ] Sensitive data is encrypted
|
||||
- [ ] N/A (not security-related)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Impact | 性能影响
|
||||
## ⚡ Performance Impact
|
||||
|
||||
- [ ] No significant performance impact | 无显著性能影响
|
||||
- [ ] Performance improved | 性能提升
|
||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响
|
||||
- [ ] No significant performance impact
|
||||
- [ ] Performance improved
|
||||
- [ ] Performance may be impacted (explain below)
|
||||
|
||||
**If impacted, explain | 如果受影响,请说明:**
|
||||
**If impacted, explain:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
|
||||
- [ ] Ran `go fmt` | 已运行 `go fmt`
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code compiles successfully (`go build`)
|
||||
- [ ] Ran `go fmt`
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
- [ ] Updated API documentation (if applicable)
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**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 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] 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! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
@@ -1,97 +1,91 @@
|
||||
# Pull Request - Documentation | 文档 PR
|
||||
# Pull Request - Documentation
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description`
|
||||
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
> **💡 Tip:** Recommended PR title format `docs(scope): description`
|
||||
> Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📚 Type of Documentation | 文档类型
|
||||
## 📚 Type of Documentation
|
||||
|
||||
- [ ] 📖 README update | README 更新
|
||||
- [ ] 📋 API documentation | API 文档
|
||||
- [ ] 🎓 Tutorial/Guide | 教程/指南
|
||||
- [ ] 📝 Code comments | 代码注释
|
||||
- [ ] 🔧 Configuration docs | 配置文档
|
||||
- [ ] 🐛 Fix typo/error | 修复拼写/错误
|
||||
- [ ] 🌍 Translation | 翻译
|
||||
- [ ] 📖 README update
|
||||
- [ ] 📋 API documentation
|
||||
- [ ] 🎓 Tutorial/Guide
|
||||
- [ ] 📝 Code comments
|
||||
- [ ] 🔧 Configuration docs
|
||||
- [ ] 🐛 Fix typo/error
|
||||
- [ ] 🌍 Translation
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots (if applicable) | 截图(如适用)
|
||||
## 📸 Screenshots (if applicable)
|
||||
|
||||
<!-- For documentation with images, diagrams, or UI examples -->
|
||||
<!-- 用于包含图片、图表或 UI 示例的文档 -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] English version complete | 英文版本完整
|
||||
- [ ] Chinese version complete | 中文版本完整
|
||||
- [ ] Both versions are consistent | 两个版本内容一致
|
||||
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
|
||||
- [ ] English version complete
|
||||
- [ ] Chinese version complete
|
||||
- [ ] Both versions are consistent
|
||||
- [ ] N/A (only one language needed)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Content Quality | 内容质量
|
||||
- [ ] Information is accurate and up-to-date | 信息准确且最新
|
||||
- [ ] Language is clear and concise | 语言清晰简洁
|
||||
- [ ] No spelling or grammar errors | 无拼写或语法错误
|
||||
- [ ] Links are valid and working | 链接有效且可用
|
||||
- [ ] Code examples are tested and working | 代码示例已测试且可用
|
||||
- [ ] Formatting is consistent | 格式一致
|
||||
### Content Quality
|
||||
- [ ] Information is accurate and up-to-date
|
||||
- [ ] Language is clear and concise
|
||||
- [ ] No spelling or grammar errors
|
||||
- [ ] Links are valid and working
|
||||
- [ ] Code examples are tested and working
|
||||
- [ ] Formatting is consistent
|
||||
|
||||
### Documentation Standards | 文档标准
|
||||
- [ ] Follows project documentation style | 遵循项目文档风格
|
||||
- [ ] Includes necessary examples | 包含必要的示例
|
||||
- [ ] Technical terms are explained | 技术术语已解释
|
||||
- [ ] Self-review completed | 已完成自查
|
||||
### Documentation Standards
|
||||
- [ ] Follows project documentation style
|
||||
- [ ] Includes necessary examples
|
||||
- [ ] Technical terms are explained
|
||||
- [ ] Self-review completed
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**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 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] 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! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
@@ -1,119 +1,113 @@
|
||||
# Pull Request - Frontend | 前端 PR
|
||||
# Pull Request - Frontend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots / Demo | 截图/演示
|
||||
## 📸 Screenshots / Demo
|
||||
|
||||
<!-- For UI changes, include before/after screenshots or video demo -->
|
||||
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
|
||||
|
||||
**Before | 变更前:**
|
||||
**Before:**
|
||||
|
||||
|
||||
**After | 变更后:**
|
||||
**After:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Node Version | Node 版本:**
|
||||
- **Browser(s) | 浏览器:**
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Node Version:**
|
||||
- **Browser(s):**
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested in development mode | 开发模式测试通过
|
||||
- [ ] Tested production build | 生产构建测试通过
|
||||
- [ ] Tested on multiple browsers | 多浏览器测试通过
|
||||
- [ ] Tested responsive design | 响应式设计测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested in development mode
|
||||
- [ ] Tested production build
|
||||
- [ ] Tested on multiple browsers
|
||||
- [ ] Tested responsive design
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] All user-facing text supports i18n
|
||||
- [ ] Both English and Chinese versions provided
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
|
||||
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
|
||||
- [ ] No console errors or warnings | 无控制台错误或警告
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code builds successfully (`npm run build`)
|
||||
- [ ] Ran `npm run lint`
|
||||
- [ ] No console errors or warnings
|
||||
|
||||
### Testing | 测试
|
||||
- [ ] Component tests added/updated | 已添加/更新组件测试
|
||||
- [ ] Tests pass locally | 测试在本地通过
|
||||
### Testing
|
||||
- [ ] Component tests added/updated
|
||||
- [ ] Tests pass locally
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
|
||||
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Updated type definitions (TypeScript)
|
||||
- [ ] Added JSDoc comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**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 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] 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! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
@@ -1,98 +1,93 @@
|
||||
# Pull Request - General | 通用 PR
|
||||
# Pull Request - General
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
**Test details | 测试详情:**
|
||||
**Test details:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] No new warnings or errors | 无新的警告或错误
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] No new warnings or errors
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security (if applicable) | 安全(如适用)
|
||||
## 🔒 Security (if applicable)
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**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 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] 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! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
13
.github/workflows/docker-build.yml
vendored
13
.github/workflows/docker-build.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- release/stable
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
@@ -152,12 +153,14 @@ jobs:
|
||||
env:
|
||||
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
|
||||
run: |
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
# Convert branch name: release/stable -> release-stable (matching Docker metadata action)
|
||||
REF_NAME=$(echo "${{ github.ref_name }}" | sed 's/\//-/g')
|
||||
GHCR_IMAGE="${{ env.REGISTRY_GHCR }}/${IMAGE_BASE}/nofx-${{ matrix.image_suffix }}"
|
||||
|
||||
echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
|
||||
echo "Repository: ${IMAGE_BASE}"
|
||||
echo "Image: ${GHCR_IMAGE}"
|
||||
echo "Ref name: ${REF_NAME}"
|
||||
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
@@ -171,6 +174,14 @@ jobs:
|
||||
echo "✅ Created latest tag (main branch only)"
|
||||
fi
|
||||
|
||||
# release/stable branch gets the 'stable' tag
|
||||
if [[ "${{ github.ref }}" == "refs/heads/release/stable" ]]; then
|
||||
docker buildx imagetools create -t "${GHCR_IMAGE}:stable" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||
"${GHCR_IMAGE}:${REF_NAME}-arm64"
|
||||
echo "✅ Created stable tag (release/stable branch)"
|
||||
fi
|
||||
|
||||
if [[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]]; then
|
||||
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
|
||||
docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \
|
||||
|
||||
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:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest-template:
|
||||
label-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Analyze PR files and auto-apply template
|
||||
- name: Analyze PR and apply labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -25,166 +21,72 @@ jobs:
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
|
||||
let additions = 0, deletions = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.filename.toLowerCase();
|
||||
if (filename.endsWith('.go')) goFiles++;
|
||||
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
|
||||
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
|
||||
else if (filename.endsWith('.md')) mdFiles++;
|
||||
const name = file.filename.toLowerCase();
|
||||
additions += file.additions || 0;
|
||||
deletions += file.deletions || 0;
|
||||
if (name.endsWith('.go')) goFiles++;
|
||||
else if (name.endsWith('.js') || name.endsWith('.jsx')) jsFiles++;
|
||||
else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.vue')) tsFiles++;
|
||||
else if (name.endsWith('.md')) mdFiles++;
|
||||
else otherFiles++;
|
||||
}
|
||||
|
||||
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
|
||||
if (totalFiles === 0) { console.log('No files changed'); return; }
|
||||
if (totalFiles === 0) return;
|
||||
|
||||
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
|
||||
// --- Scope label ---
|
||||
const labels = [];
|
||||
if (goFiles / totalFiles > 0.5) labels.push('backend');
|
||||
else if ((jsFiles + tsFiles) / totalFiles > 0.5) labels.push('frontend');
|
||||
else if (mdFiles / totalFiles > 0.7) labels.push('documentation');
|
||||
else labels.push('fullstack');
|
||||
|
||||
if (goFiles / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
|
||||
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
|
||||
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
|
||||
} else if (mdFiles / totalFiles > 0.7) {
|
||||
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
|
||||
// --- Size label (like OpenClaw) ---
|
||||
const totalChanged = additions + deletions;
|
||||
const sizeLabels = ['size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'];
|
||||
let sizeLabel = 'size: XL';
|
||||
if (totalChanged < 50) sizeLabel = 'size: XS';
|
||||
else if (totalChanged < 200) sizeLabel = 'size: S';
|
||||
else if (totalChanged < 500) sizeLabel = 'size: M';
|
||||
else if (totalChanged < 1000) sizeLabel = 'size: L';
|
||||
labels.push(sizeLabel);
|
||||
|
||||
// Ensure size labels exist
|
||||
for (const sl of sizeLabels) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl });
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: sl, color: 'b76e79' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
// Remove stale size labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number,
|
||||
});
|
||||
for (const cl of currentLabels) {
|
||||
if (sizeLabels.includes(cl.name) && cl.name !== sizeLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, name: cl.name,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply labels
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
issue_number: context.issue.number,
|
||||
labels: labels,
|
||||
});
|
||||
|
||||
const prBody = pr.body || '';
|
||||
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
|
||||
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
|
||||
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
|
||||
const usesGeneralTemplate = prBody.includes('Pull Request - General');
|
||||
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
|
||||
|
||||
if (templateLabel) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: [templateLabel]
|
||||
});
|
||||
console.log('Added label: ' + templateLabel);
|
||||
} catch (error) {
|
||||
console.log('Label might not exist, skipping...');
|
||||
}
|
||||
}
|
||||
|
||||
function isPRBodyEmpty(body) {
|
||||
if (!body || body.trim().length < 100) return true;
|
||||
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
|
||||
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
|
||||
if (hasEmptyDescription || hasEmptyChanges) return true;
|
||||
const descMatch = body.match(/\*\*English:\*\*[||]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
|
||||
if (!descMatch || descMatch[1].trim().length < 10) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (suggestedTemplate && usingDefaultTemplate) {
|
||||
const shouldAutoApply = isPRBodyEmpty(prBody);
|
||||
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
if (shouldAutoApply) {
|
||||
try {
|
||||
const { data: templateFile } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: templatePath,
|
||||
ref: context.payload.pull_request.head.ref
|
||||
});
|
||||
|
||||
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
body: templateContent
|
||||
});
|
||||
|
||||
console.log('Auto-applied ' + suggestedTemplate + ' template');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
|
||||
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
|
||||
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
|
||||
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
|
||||
'✨ You can now fill in the relevant information in the PR description!';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: notifyComment
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.log('Failed to fetch or apply template: ' + error.message);
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
|
||||
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
|
||||
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fallbackComment
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('PR body has content, sending suggestion only');
|
||||
|
||||
let fileStats = [];
|
||||
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
|
||||
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
|
||||
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
|
||||
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
|
||||
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
|
||||
const fileStatsText = fileStats.join('\n');
|
||||
|
||||
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
|
||||
|
||||
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
|
||||
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
|
||||
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
|
||||
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
|
||||
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
|
||||
'**如何使用 | How to use**\n' +
|
||||
'1. 编辑PR描述 | Edit PR description\n' +
|
||||
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
|
||||
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
|
||||
' `?template=' + suggestedTemplate + '.md`\n\n' +
|
||||
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
|
||||
'_This is an automated suggestion. You may continue using the current template._';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
} else if (suggestedTemplate && !usingDefaultTemplate) {
|
||||
console.log('PR already uses a specific template');
|
||||
} else {
|
||||
console.log('No specific template suggestion needed - mixed changes');
|
||||
}
|
||||
console.log(`Applied labels: ${labels.join(', ')} (${totalChanged} lines changed)`);
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ nofx_test
|
||||
# Go 相关
|
||||
*.test
|
||||
*.out
|
||||
.gocache/
|
||||
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
|
||||
40
Dockerfile.railway
Normal file
40
Dockerfile.railway
Normal file
@@ -0,0 +1,40 @@
|
||||
# Railway All-in-One: 复用现有 GHCR 镜像
|
||||
# 从现有镜像提取内容,合并到一个容器
|
||||
|
||||
# 从后端镜像提取二进制
|
||||
FROM ghcr.io/nofxaios/nofx/nofx-backend:latest AS backend
|
||||
|
||||
# 从前端镜像提取静态文件
|
||||
FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend
|
||||
|
||||
# 最终镜像
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext
|
||||
|
||||
# 复制后端二进制
|
||||
COPY --from=backend /app/nofx /app/nofx
|
||||
|
||||
# 复制 TA-Lib 库
|
||||
COPY --from=backend /usr/local/lib/libta_lib* /usr/local/lib/
|
||||
RUN ldconfig /usr/local/lib 2>/dev/null || true
|
||||
|
||||
# 复制前端静态文件
|
||||
COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# 启动脚本(包含 nginx 配置生成)
|
||||
COPY railway/start.sh /app/start.sh
|
||||
RUN chmod +x /app/start.sh
|
||||
|
||||
ENV DB_PATH=/app/data/data.db
|
||||
|
||||
# Railway 会自动设置 PORT 环境变量
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8080}/health || exit 1
|
||||
|
||||
CMD ["/app/start.sh"]
|
||||
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バトル
|
||||
|
||||
145
README.md
145
README.md
@@ -1,9 +1,21 @@
|
||||
# NOFX - Agentic Trading OS
|
||||
<h1 align="center">NOFX — Open Source AI Trading OS</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>The infrastructure layer for AI-powered financial trading.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
| CONTRIBUTOR AIRDROP PROGRAM |
|
||||
|:----------------------------------:|
|
||||
@@ -14,10 +26,6 @@
|
||||
|
||||
---
|
||||
|
||||
## AI-Powered Multi-Asset Trading Platform
|
||||
|
||||
**NOFX** is an open-source AI trading system that lets you run multiple AI models to trade automatically. Configure strategies through a web interface, monitor performance in real-time, and let AI agents compete to find the best trading approach.
|
||||
|
||||
### Supported Markets
|
||||
|
||||
| Market | Trading | Status |
|
||||
@@ -30,7 +38,7 @@
|
||||
### Core Features
|
||||
|
||||
- **Multi-AI Support**: Run DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - switch models anytime
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Multi-Exchange**: Trade on Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter from one platform
|
||||
- **Strategy Studio**: Visual strategy builder with coin sources, indicators, and risk controls
|
||||
- **AI Debate Arena**: Multiple AI models debate trading decisions with different roles (Bull, Bear, Analyst)
|
||||
- **AI Competition Mode**: Multiple AI traders compete in real-time, track performance side by side
|
||||
@@ -42,6 +50,12 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### Official Links
|
||||
|
||||
- **Official Website**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
|
||||
|
||||
## Developer Community
|
||||
@@ -50,6 +64,52 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To use NOFX, you'll need:
|
||||
|
||||
1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
|
||||
2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
|
||||
|
||||
---
|
||||
|
||||
## Supported Exchanges
|
||||
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Config Page
|
||||
@@ -87,44 +147,9 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Supported Exchanges
|
||||
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-Click Install (Recommended)
|
||||
### One-Click Install (Local/Server)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -133,6 +158,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
That's it! Open **http://127.0.0.1:3000** in your browser.
|
||||
|
||||
### One-Click Cloud Deploy (Railway)
|
||||
|
||||
Deploy to Railway with one click - no server setup required:
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
After deployment, Railway will provide a public URL to access your NOFX instance.
|
||||
|
||||
### Docker Compose (Manual)
|
||||
|
||||
```bash
|
||||
@@ -465,6 +498,26 @@ All contributions are tracked on GitHub. When NOFX generates revenue, contributo
|
||||
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to all our sponsors!
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="60" height="60" style="border-radius:50%" alt="pjl914335852-ux" /></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="60" height="60" style="border-radius:50%" alt="cat9999aaa" /></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="60" height="60" style="border-radius:50%" alt="1733055465" /></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="60" height="60" style="border-radius:50%" alt="kolal2020" /></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="60" height="60" style="border-radius:50%" alt="CyberFFarm" /></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="60" height="60" style="border-radius:50%" alt="vip3001003" /></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="60" height="60" style="border-radius:50%" alt="mrtluh" /></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="60" height="60" style="border-radius:50%" alt="cpcp1117-source" /></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="60" height="60" style="border-radius:50%" alt="match-007" /></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="60" height="60" style="border-radius:50%" alt="leiwuhen1715" /></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="60" height="60" style="border-radius:50%" alt="SHAOXIA1991" /></a>
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"nofx/backtest"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
|
||||
var req backtestStartRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -78,23 +78,23 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if cfg.StrategyID != "" {
|
||||
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to load strategy: %v", err)})
|
||||
SafeBadRequest(c, "Failed to load strategy")
|
||||
return
|
||||
}
|
||||
if strategy == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("strategy not found: %s", cfg.StrategyID)})
|
||||
SafeBadRequest(c, "Strategy not found")
|
||||
return
|
||||
}
|
||||
var strategyConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to parse strategy config: %v", err)})
|
||||
SafeBadRequest(c, "Failed to parse strategy config")
|
||||
return
|
||||
}
|
||||
cfg.SetLoadedStrategy(&strategyConfig)
|
||||
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
||||
logger.Infof("📊 Strategy coin source: type=%s, use_coin_pool=%v, use_oi_top=%v, static_coins=%v",
|
||||
logger.Infof("📊 Strategy coin source: type=%s, use_ai500=%v, use_oi_top=%v, static_coins=%v",
|
||||
strategyConfig.CoinSource.SourceType,
|
||||
strategyConfig.CoinSource.UseCoinPool,
|
||||
strategyConfig.CoinSource.UseAI500,
|
||||
strategyConfig.CoinSource.UseOITop,
|
||||
strategyConfig.CoinSource.StaticCoins)
|
||||
|
||||
@@ -102,7 +102,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
if len(cfg.Symbols) == 0 {
|
||||
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to resolve coins from strategy: %v", err)})
|
||||
SafeBadRequest(c, "Failed to resolve coins from strategy")
|
||||
return
|
||||
}
|
||||
cfg.Symbols = symbols
|
||||
@@ -111,7 +111,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Failed to configure AI model")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
||||
|
||||
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,11 +149,11 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if req.RunID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
||||
}
|
||||
|
||||
if err := fn(req.RunID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to execute backtest operation", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||
}
|
||||
var req labelRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
@@ -194,7 +194,7 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
|
||||
}
|
||||
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Update backtest label", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, meta)
|
||||
@@ -207,11 +207,11 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||
}
|
||||
var req runIDRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RunID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
||||
SafeBadRequest(c, "run_id is required")
|
||||
return
|
||||
}
|
||||
userID := normalizeUserID(c.GetString("user_id"))
|
||||
@@ -219,7 +219,7 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if err := s.backtestManager.Delete(req.RunID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Delete backtest run", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
@@ -277,7 +277,7 @@ func (s *Server) handleBacktestRuns(c *gin.Context) {
|
||||
|
||||
metas, err := s.backtestManager.ListRuns()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "List backtest runs", err)
|
||||
return
|
||||
}
|
||||
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
|
||||
@@ -349,7 +349,7 @@ func (s *Server) handleBacktestEquity(c *gin.Context) {
|
||||
|
||||
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, points)
|
||||
@@ -375,7 +375,7 @@ func (s *Server) handleBacktestTrades(c *gin.Context) {
|
||||
|
||||
events, err := s.backtestManager.LoadTrades(runID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
@@ -404,7 +404,7 @@ func (s *Server) handleBacktestMetrics(c *gin.Context) {
|
||||
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, metrics)
|
||||
@@ -427,7 +427,7 @@ func (s *Server) handleBacktestTrace(c *gin.Context) {
|
||||
cycle := queryInt(c, "cycle", 0)
|
||||
record, err := s.backtestManager.GetTrace(runID, cycle)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trace record")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, record)
|
||||
@@ -461,7 +461,7 @@ func (s *Server) handleBacktestDecisions(c *gin.Context) {
|
||||
|
||||
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Load decision records", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
@@ -483,7 +483,7 @@ func (s *Server) handleBacktestExport(c *gin.Context) {
|
||||
}
|
||||
path, err := s.backtestManager.ExportRun(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
|
||||
return
|
||||
}
|
||||
defer os.Remove(path)
|
||||
@@ -536,8 +536,7 @@ func (s *Server) handleBacktestKlines(c *gin.Context) {
|
||||
|
||||
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to fetch klines for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to fetch klines: %v", err)})
|
||||
SafeInternalError(c, "Fetch klines", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -620,11 +619,11 @@ func writeBacktestAccessError(c *gin.Context, err error) bool {
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errBacktestForbidden):
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "No permission to access this backtest task"})
|
||||
SafeForbidden(c, "No permission to access this backtest task")
|
||||
case errors.Is(err, os.ErrNotExist), errors.Is(err, sql.ErrNoRows):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backtest task does not exist"})
|
||||
SafeNotFound(c, "Backtest task")
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Access backtest", err)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -639,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
var symbols []string
|
||||
symbolSet := make(map[string]bool)
|
||||
|
||||
// Set custom API URLs if provided
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
|
||||
// Handle empty source_type - check flags for backward compatibility
|
||||
sourceType := coinSource.SourceType
|
||||
if sourceType == "" {
|
||||
if coinSource.UseCoinPool && coinSource.UseOITop {
|
||||
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||
sourceType = "mixed"
|
||||
} else if coinSource.UseCoinPool {
|
||||
sourceType = "coinpool"
|
||||
} else if coinSource.UseAI500 {
|
||||
sourceType = "ai500"
|
||||
} else if coinSource.UseOITop {
|
||||
sourceType = "oi_top"
|
||||
} else if len(coinSource.StaticCoins) > 0 {
|
||||
@@ -674,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
}
|
||||
|
||||
case "coinpool":
|
||||
limit := coinSource.CoinPoolLimit
|
||||
case "ai500":
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||
coins, err := provider.GetTopRatedCoins(limit)
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
||||
}
|
||||
@@ -694,7 +685,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
|
||||
case "oi_top":
|
||||
coins, err := provider.GetOITopSymbols()
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
||||
}
|
||||
@@ -714,13 +705,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
}
|
||||
|
||||
case "mixed":
|
||||
// Get from coin pool
|
||||
if coinSource.UseCoinPool {
|
||||
limit := coinSource.CoinPoolLimit
|
||||
// Get from AI500
|
||||
if coinSource.UseAI500 {
|
||||
limit := coinSource.AI500Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
coins, err := provider.GetTopRatedCoins(limit)
|
||||
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||
} else {
|
||||
@@ -736,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
||||
|
||||
// Get from OI Top
|
||||
if coinSource.UseOITop {
|
||||
coins, err := provider.GetOITopSymbols()
|
||||
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||
} else {
|
||||
@@ -841,6 +832,8 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error {
|
||||
provider = "google"
|
||||
} else if strings.Contains(modelNameLower, "deepseek") {
|
||||
provider = "deepseek"
|
||||
} else if strings.Contains(modelNameLower, "minimax") {
|
||||
provider = "minimax"
|
||||
} else if model.CustomAPIURL != "" {
|
||||
provider = "custom"
|
||||
} else {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"nofx/debate"
|
||||
"nofx/logger"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -131,7 +131,7 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
|
||||
var req CreateDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
||||
if len(coinSource.StaticCoins) > 0 {
|
||||
req.Symbol = coinSource.StaticCoins[0]
|
||||
}
|
||||
case "coinpool":
|
||||
// Fetch from coin pool API
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
case "ai500":
|
||||
// Fetch from AI500 API
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from pool API: %s", req.Symbol)
|
||||
logger.Infof("Fetched coin from AI500 API: %s", req.Symbol)
|
||||
}
|
||||
case "oi_top":
|
||||
// Fetch from OI top API
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||
}
|
||||
case "mixed":
|
||||
// Try coin pool first, then OI top
|
||||
if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
// Try AI500 first, then OI top
|
||||
if coinSource.UseAI500 {
|
||||
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from pool API (mixed): %s", req.Symbol)
|
||||
logger.Infof("Fetched coin from AI500 API (mixed): %s", req.Symbol)
|
||||
}
|
||||
} else if coinSource.UseOITop && coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
} else if coinSource.UseOITop {
|
||||
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||
req.Symbol = coins[0]
|
||||
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
||||
}
|
||||
@@ -292,7 +284,7 @@ func (h *DebateHandler) HandleStartDebate(c *gin.Context) {
|
||||
|
||||
// Start debate asynchronously
|
||||
if err := h.engine.StartDebate(debateID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Start debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +308,7 @@ func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.engine.CancelDebate(debateID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Cancel debate", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -495,20 +487,20 @@ func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
|
||||
// Parse request
|
||||
var req ExecuteDebateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get trader executor
|
||||
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("trader not available: %v", err)})
|
||||
SafeError(c, http.StatusBadRequest, "Trader not available", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute consensus
|
||||
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
SafeInternalError(c, "Execute consensus", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -635,7 +627,9 @@ func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.Deb
|
||||
}
|
||||
|
||||
func (h *DebateHandler) broadcastError(sessionID string, err error) {
|
||||
// Sanitize error message before broadcasting to client
|
||||
safeMsg := SanitizeError(err, "An error occurred during debate")
|
||||
h.broadcast(sessionID, "error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"error": safeMsg,
|
||||
})
|
||||
}
|
||||
|
||||
95
api/errors.go
Normal file
95
api/errors.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// SafeError returns a safe error message without exposing internal details
|
||||
// It logs the actual error for debugging but returns a generic message to the client
|
||||
func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) {
|
||||
// Log the actual error internally
|
||||
if internalErr != nil {
|
||||
logger.Errorf("[API Error] %s: %v", publicMsg, internalErr)
|
||||
}
|
||||
|
||||
c.JSON(statusCode, gin.H{"error": publicMsg})
|
||||
}
|
||||
|
||||
// SafeInternalError logs internal error and returns a generic message
|
||||
func SafeInternalError(c *gin.Context, operation string, err error) {
|
||||
logger.Errorf("[Internal Error] %s: %v", operation, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"})
|
||||
}
|
||||
|
||||
// SafeBadRequest returns a safe bad request error
|
||||
// For validation errors, we can be more specific since they're about user input
|
||||
func SafeBadRequest(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// SafeNotFound returns a generic not found error
|
||||
func SafeNotFound(c *gin.Context, resource string) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"})
|
||||
}
|
||||
|
||||
// SafeUnauthorized returns unauthorized error
|
||||
func SafeUnauthorized(c *gin.Context) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
}
|
||||
|
||||
// SafeForbidden returns forbidden error
|
||||
func SafeForbidden(c *gin.Context, msg string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// IsSensitiveError checks if an error message contains sensitive information
|
||||
func IsSensitiveError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
|
||||
sensitivePatterns := []string{
|
||||
// Database
|
||||
"postgres", "mysql", "sqlite", "database", "sql",
|
||||
"connection", "connect", "failed to connect",
|
||||
// Network
|
||||
"dial", "tcp", "udp", "socket", "timeout",
|
||||
// Server info
|
||||
"127.0.0.1", "localhost", "0.0.0.0",
|
||||
// File system
|
||||
"no such file", "permission denied", "open /",
|
||||
// Credentials
|
||||
"password", "user=", "host=", "port=",
|
||||
// Internal
|
||||
"panic", "runtime error", "stack trace",
|
||||
}
|
||||
|
||||
for _, pattern := range sensitivePatterns {
|
||||
if strings.Contains(errMsg, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IP addresses (simple pattern)
|
||||
if strings.Contains(errMsg, ":") && (strings.Contains(errMsg, ".") || strings.Contains(errMsg, "::")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SanitizeError returns the error message if safe, otherwise returns a generic message
|
||||
func SanitizeError(err error, fallbackMsg string) string {
|
||||
if err == nil {
|
||||
return fallbackMsg
|
||||
}
|
||||
if IsSensitiveError(err) {
|
||||
return fallbackMsg
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
1056
api/server.go
1056
api/server.go
File diff suppressed because it is too large
Load Diff
159
api/strategy.go
159
api/strategy.go
@@ -4,11 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
|
||||
// Validate quant data URL if enabled
|
||||
if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" {
|
||||
if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") {
|
||||
warnings = append(warnings, "Quant data URL does not contain {symbol} placeholder. The same data will be used for all coins, which may not be correct.")
|
||||
}
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
|
||||
config.Indicators.NofxOSAPIKey == "" {
|
||||
warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
|
||||
}
|
||||
|
||||
return warnings
|
||||
@@ -33,7 +33,7 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
strategies, err := s.store.Strategy().ListPublic()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get public strategies: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to get public strategies", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get strategy list: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -136,7 +136,8 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateStrategy Create strategy
|
||||
// handleCreateStrategy Create strategy.
|
||||
// If "config" is omitted from the request body, the system default config is used automatically.
|
||||
func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -145,20 +146,31 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Use default config when none provided
|
||||
if req.Config == nil {
|
||||
lang := req.Lang
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -173,12 +185,12 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to create strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -191,7 +203,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// handleUpdateStrategy Update strategy
|
||||
// handleUpdateStrategy Update strategy.
|
||||
// The incoming config is merged with the existing one: top-level sections present in the
|
||||
// request overwrite the corresponding existing sections; absent sections are preserved.
|
||||
// This prevents partial updates from zeroing out unmentioned fields.
|
||||
func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
strategyID := c.Param("id")
|
||||
@@ -213,42 +228,67 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config store.StrategyConfig `json:"config"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config json.RawMessage `json:"config"` // raw JSON so we can merge
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
// Start with the existing config as base — preserves all unmentioned fields.
|
||||
var mergedConfig store.StrategyConfig
|
||||
if err := json.Unmarshal([]byte(existing.Config), &mergedConfig); err != nil {
|
||||
// If existing config is corrupt, start from zero
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = existing.Name
|
||||
}
|
||||
description := req.Description
|
||||
if description == "" {
|
||||
description = existing.Description
|
||||
}
|
||||
|
||||
configJSON, err := json.Marshal(mergedConfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
||||
SafeInternalError(c, "Serialize configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: strategyID,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Config: string(configJSON),
|
||||
IsPublic: req.IsPublic,
|
||||
ConfigVisible: req.ConfigVisible,
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Update(strategy); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to update strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&req.Config)
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -269,7 +309,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to delete strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,7 +327,7 @@ func (s *Server) handleActivateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to activate strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to activate strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -309,13 +349,13 @@ func (s *Server) handleDuplicateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
newID := uuid.New().String()
|
||||
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to duplicate strategy: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to duplicate strategy", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -383,7 +423,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,7 +436,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
|
||||
// Build system prompt (using built-in method from strategy engine)
|
||||
systemPrompt := engine.BuildSystemPrompt(
|
||||
@@ -433,7 +473,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request parameters: " + err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -442,13 +482,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := decision.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
|
||||
// Get candidate coins
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
logger.Errorf("[API Error] Failed to get candidate coins: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to get candidate coins: " + err.Error(),
|
||||
"error": "Failed to get candidate coins",
|
||||
"ai_response": "",
|
||||
})
|
||||
return
|
||||
@@ -502,12 +543,18 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
|
||||
// Fetch NetFlow ranking data (market-wide fund flow)
|
||||
netFlowRankingData := engine.FetchNetFlowRankingData()
|
||||
|
||||
// Fetch Price ranking data (market-wide gainers/losers)
|
||||
priceRankingData := engine.FetchPriceRankingData()
|
||||
|
||||
// Build real context (for generating User Prompt)
|
||||
testContext := &decision.Context{
|
||||
testContext := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: 1000.0,
|
||||
AvailableBalance: 1000.0,
|
||||
UnrealizedPnL: 0,
|
||||
@@ -517,12 +564,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
MarginUsedPct: 0,
|
||||
PositionCount: 0,
|
||||
},
|
||||
Positions: []decision.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
Positions: []kernel.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
}
|
||||
|
||||
// Build System Prompt
|
||||
@@ -616,6 +665,18 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
case "openai":
|
||||
aiClient = mcp.NewOpenAIClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "minimax":
|
||||
aiClient = mcp.NewMiniMaxClient()
|
||||
aiClient.SetAPIKey(apiKey, model.CustomAPIURL, model.CustomModelName)
|
||||
case "blockrun-base":
|
||||
aiClient = mcp.NewBlockRunBaseClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "blockrun-sol":
|
||||
aiClient = mcp.NewBlockRunSolClient()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
case "claw402":
|
||||
aiClient = mcp.NewClaw402Client()
|
||||
aiClient.SetAPIKey(apiKey, "", model.CustomModelName)
|
||||
default:
|
||||
// Use generic client
|
||||
aiClient = mcp.NewClient()
|
||||
|
||||
35
auth/auth.go
35
auth/auth.go
@@ -1,15 +1,12 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -25,9 +22,6 @@ var tokenBlacklist = struct {
|
||||
// maxBlacklistEntries is the maximum capacity threshold for blacklist
|
||||
const maxBlacklistEntries = 100_000
|
||||
|
||||
// OTPIssuer is the OTP issuer name
|
||||
const OTPIssuer = "nofxAI"
|
||||
|
||||
// SetJWTSecret sets the JWT secret key
|
||||
func SetJWTSecret(secret string) {
|
||||
JWTSecret = []byte(secret)
|
||||
@@ -87,30 +81,6 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateOTPSecret generates OTP secret
|
||||
func GenerateOTPSecret() (string, error) {
|
||||
secret := make([]byte, 20)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: OTPIssuer,
|
||||
AccountName: uuid.New().String(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return key.Secret(), nil
|
||||
}
|
||||
|
||||
// VerifyOTP verifies OTP code
|
||||
func VerifyOTP(secret, code string) bool {
|
||||
return totp.Validate(code, secret)
|
||||
}
|
||||
|
||||
// GenerateJWT generates JWT token
|
||||
func GenerateJWT(userID, email string) (string, error) {
|
||||
claims := Claims{
|
||||
@@ -147,8 +117,3 @@ func ValidateJWT(tokenString string) (*Claims, error) {
|
||||
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GetOTPQRCodeURL gets OTP QR code URL
|
||||
func GetOTPQRCodeURL(secret, email string) string {
|
||||
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s", OTPIssuer, email, secret, OTPIssuer)
|
||||
}
|
||||
|
||||
@@ -122,10 +122,10 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||
notional := execPrice * quantity
|
||||
closingFee := notional * acc.feeRate
|
||||
closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
|
||||
closingFee := closeNotional * acc.feeRate
|
||||
|
||||
// Calculate proportional opening fee for the quantity being closed
|
||||
// Calculate proportional values based on the portion being closed
|
||||
closePortion := quantity / pos.Quantity
|
||||
openingFeePortion := pos.AccumulatedFee * closePortion
|
||||
totalFee := closingFee + openingFeePortion
|
||||
@@ -133,13 +133,17 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
|
||||
realized := realizedPnL(pos, quantity, execPrice)
|
||||
|
||||
marginPortion := pos.Margin * closePortion
|
||||
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
|
||||
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
|
||||
entryNotionalPortion := pos.Notional * closePortion
|
||||
|
||||
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
|
||||
acc.cash += marginPortion + realized - closingFee
|
||||
// But for realized P&L tracking, we include both fees
|
||||
acc.realizedPnL += realized - totalFee
|
||||
|
||||
pos.Quantity -= quantity
|
||||
pos.Notional -= notional
|
||||
pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
|
||||
pos.Margin -= marginPortion
|
||||
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
||||
|
||||
|
||||
@@ -71,6 +71,34 @@ func configureMCPClient(cfg BacktestConfig, base mcp.AIClient) (mcp.AIClient, er
|
||||
oaiC := mcp.NewOpenAIClientWithOptions()
|
||||
oaiC.(*mcp.OpenAIClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return oaiC, nil
|
||||
case "minimax":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("minimax provider requires api key")
|
||||
}
|
||||
mmC := mcp.NewMiniMaxClientWithOptions()
|
||||
mmC.(*mcp.MiniMaxClient).SetAPIKey(cfg.AICfg.APIKey, cfg.AICfg.BaseURL, cfg.AICfg.Model)
|
||||
return mmC, nil
|
||||
case "blockrun-base":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-base provider requires wallet private key")
|
||||
}
|
||||
brBase := mcp.NewBlockRunBaseClient()
|
||||
brBase.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brBase, nil
|
||||
case "blockrun-sol":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("blockrun-sol provider requires wallet keypair")
|
||||
}
|
||||
brSol := mcp.NewBlockRunSolClient()
|
||||
brSol.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return brSol, nil
|
||||
case "claw402":
|
||||
if cfg.AICfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("claw402 provider requires wallet private key")
|
||||
}
|
||||
claw := mcp.NewClaw402Client()
|
||||
claw.SetAPIKey(cfg.AICfg.APIKey, "", cfg.AICfg.Model)
|
||||
return claw, nil
|
||||
case "custom":
|
||||
if cfg.AICfg.BaseURL == "" || cfg.AICfg.APIKey == "" || cfg.AICfg.Model == "" {
|
||||
return nil, fmt.Errorf("custom provider requires base_url, api key and model")
|
||||
@@ -125,6 +153,11 @@ func cloneBaseClient(base mcp.AIClient) *mcp.Client {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
case *mcp.MiniMaxClient:
|
||||
if c != nil && c.Client != nil {
|
||||
cp := *c.Client
|
||||
return &cp
|
||||
}
|
||||
}
|
||||
// Fall back to a new default client
|
||||
return mcp.NewClient().(*mcp.Client)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ type cachedDecision struct {
|
||||
Key string `json:"key"`
|
||||
PromptVariant string `json:"prompt_variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Decision *decision.FullDecision `json:"decision"`
|
||||
Decision *kernel.FullDecision `json:"decision"`
|
||||
}
|
||||
|
||||
// AICache persists AI decisions for repeated backtesting or replay.
|
||||
@@ -67,7 +67,7 @@ func (c *AICache) Path() string {
|
||||
return c.path
|
||||
}
|
||||
|
||||
func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
||||
func (c *AICache) Get(key string) (*kernel.FullDecision, bool) {
|
||||
if c == nil || key == "" {
|
||||
return nil, false
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
||||
return cloneDecision(entry.Decision), true
|
||||
}
|
||||
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *decision.FullDecision) error {
|
||||
func (c *AICache) Put(key string, variant string, ts int64, decision *kernel.FullDecision) error {
|
||||
if c == nil || key == "" || decision == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (c *AICache) save() error {
|
||||
return writeFileAtomic(c.path, data, 0o644)
|
||||
}
|
||||
|
||||
func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
||||
func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -117,14 +117,14 @@ func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var dst decision.FullDecision
|
||||
var dst kernel.FullDecision
|
||||
if err := json.Unmarshal(data, &dst); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
|
||||
func computeCacheKey(ctx *decision.Context, variant string, ts int64) (string, error) {
|
||||
func computeCacheKey(ctx *kernel.Context, variant string, ts int64) (string, error) {
|
||||
if ctx == nil {
|
||||
return "", fmt.Errorf("context is nil")
|
||||
}
|
||||
@@ -132,9 +132,9 @@ func computeCacheKey(ctx *decision.Context, variant string, ts int64) (string, e
|
||||
Variant string `json:"variant"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
Account decision.AccountInfo `json:"account"`
|
||||
Positions []decision.PositionInfo `json:"positions"`
|
||||
CandidateCoins []decision.CandidateCoin `json:"candidate_coins"`
|
||||
Account kernel.AccountInfo `json:"account"`
|
||||
Positions []kernel.PositionInfo `json:"positions"`
|
||||
CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
|
||||
MarketData map[string]market.Data `json:"market"`
|
||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||
Runtime int `json:"runtime_minutes"`
|
||||
|
||||
@@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
if len(cfg.Symbols) > 0 {
|
||||
result.CoinSource.SourceType = "static"
|
||||
result.CoinSource.StaticCoins = cfg.Symbols
|
||||
result.CoinSource.UseCoinPool = false
|
||||
result.CoinSource.UseAI500 = false
|
||||
result.CoinSource.UseOITop = false
|
||||
}
|
||||
|
||||
@@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
||||
|
||||
return &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseCoinPool: false,
|
||||
CoinPoolLimit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
SourceType: "static",
|
||||
StaticCoins: cfg.Symbols,
|
||||
UseAI500: false,
|
||||
AI500Limit: len(cfg.Symbols),
|
||||
UseOITop: false,
|
||||
OITopLimit: 0,
|
||||
},
|
||||
Indicators: store.IndicatorConfig{
|
||||
Klines: store.KlineConfig{
|
||||
|
||||
@@ -124,11 +124,23 @@ func (df *DataFeed) DecisionBarCount() int {
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
||||
// Bounds check to prevent panic
|
||||
if index < 0 || index >= len(df.decisionTimes) {
|
||||
return 0
|
||||
}
|
||||
return df.decisionTimes[index]
|
||||
}
|
||||
|
||||
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
||||
series := df.symbolSeries[symbol].byTF[tf]
|
||||
// Nil checks to prevent panic
|
||||
ss, ok := df.symbolSeries[symbol]
|
||||
if !ok || ss == nil {
|
||||
return nil
|
||||
}
|
||||
series, ok := ss.byTF[tf]
|
||||
if !ok || series == nil {
|
||||
return nil
|
||||
}
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] > ts
|
||||
})
|
||||
|
||||
@@ -91,8 +91,13 @@ func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
||||
return maxDD
|
||||
}
|
||||
|
||||
// sharpeRatio calculates the Sharpe ratio from equity points.
|
||||
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
|
||||
// Returns math.NaN() for edge cases (insufficient data, zero variance).
|
||||
func sharpeRatio(points []EquityPoint) float64 {
|
||||
if len(points) < 2 {
|
||||
// Need at least 10 data points for meaningful Sharpe calculation
|
||||
const minDataPoints = 10
|
||||
if len(points) < minDataPoints {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -108,34 +113,42 @@ func sharpeRatio(points []EquityPoint) float64 {
|
||||
returns = append(returns, ret)
|
||||
prev = curr
|
||||
}
|
||||
if len(returns) == 0 {
|
||||
if len(returns) < minDataPoints-1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate mean return
|
||||
mean := 0.0
|
||||
for _, r := range returns {
|
||||
mean += r
|
||||
}
|
||||
mean /= float64(len(returns))
|
||||
|
||||
// Calculate sample variance (using n-1 for unbiased estimator)
|
||||
variance := 0.0
|
||||
for _, r := range returns {
|
||||
diff := r - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(returns))
|
||||
if len(returns) > 1 {
|
||||
variance /= float64(len(returns) - 1)
|
||||
}
|
||||
|
||||
std := math.Sqrt(variance)
|
||||
if std == 0 {
|
||||
if mean > 0 {
|
||||
return 999
|
||||
}
|
||||
if mean < 0 {
|
||||
return -999
|
||||
}
|
||||
if std < 1e-10 {
|
||||
// Zero or near-zero volatility - return 0 instead of infinity/NaN
|
||||
return 0
|
||||
}
|
||||
return mean / std
|
||||
|
||||
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
|
||||
// Annualize by multiplying by sqrt(periods per year)
|
||||
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
|
||||
// For conservative estimate, use sqrt(252) as if daily returns
|
||||
periodsPerYear := 252.0
|
||||
annualizationFactor := math.Sqrt(periodsPerYear)
|
||||
|
||||
sharpe := (mean / std) * annualizationFactor
|
||||
return sharpe
|
||||
}
|
||||
|
||||
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
@@ -189,7 +202,8 @@ func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
if totalLossAmount > 0 {
|
||||
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||
} else if totalWinAmount > 0 {
|
||||
metrics.ProfitFactor = 999
|
||||
// No losses but have wins - use a high but reasonable cap
|
||||
metrics.ProfitFactor = 100.0
|
||||
}
|
||||
|
||||
bestSymbol := ""
|
||||
|
||||
@@ -2,15 +2,39 @@ package backtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var persistenceDB *sql.DB
|
||||
var dbIsPostgres bool
|
||||
|
||||
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
||||
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
|
||||
func UseDatabase(db *sql.DB) {
|
||||
persistenceDB = db
|
||||
}
|
||||
|
||||
// UseDatabaseWithType enables database-backed persistence with explicit type.
|
||||
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
|
||||
persistenceDB = db
|
||||
dbIsPostgres = isPostgres
|
||||
}
|
||||
|
||||
func usingDB() bool {
|
||||
return persistenceDB != nil
|
||||
}
|
||||
|
||||
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
|
||||
func convertQuery(query string) string {
|
||||
if !dbIsPostgres {
|
||||
return query
|
||||
}
|
||||
result := query
|
||||
index := 1
|
||||
for strings.Contains(result, "?") {
|
||||
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -73,12 +73,12 @@ func enforceRetentionDB(maxRuns int) {
|
||||
RunStateFailed,
|
||||
RunStateLiquidated,
|
||||
}
|
||||
query := `
|
||||
query := convertQuery(`
|
||||
SELECT run_id FROM backtest_runs
|
||||
WHERE state IN (?, ?, ?, ?)
|
||||
ORDER BY updated_at DESC
|
||||
OFFSET ?
|
||||
`
|
||||
`)
|
||||
rows, err := persistenceDB.Query(query,
|
||||
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
@@ -34,7 +34,7 @@ type Runner struct {
|
||||
cfg BacktestConfig
|
||||
feed *DataFeed
|
||||
account *BacktestAccount
|
||||
strategyEngine *decision.StrategyEngine
|
||||
strategyEngine *kernel.StrategyEngine
|
||||
|
||||
decisionLogDir string
|
||||
mcpClient mcp.AIClient
|
||||
@@ -60,8 +60,9 @@ type Runner struct {
|
||||
aiCache *AICache
|
||||
cachePath string
|
||||
|
||||
lockInfo *RunLockInfo
|
||||
lockStop chan struct{}
|
||||
lockInfo *RunLockInfo
|
||||
lockStop chan struct{}
|
||||
lockStopOnce sync.Once // Ensures lockStop is closed only once
|
||||
}
|
||||
|
||||
// NewRunner constructs a backtest runner.
|
||||
@@ -118,7 +119,7 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) {
|
||||
|
||||
// Create strategy engine from backtest config for unified prompt generation
|
||||
strategyConfig := cfg.ToStrategyConfig()
|
||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||
|
||||
r := &Runner{
|
||||
cfg: cfg,
|
||||
@@ -175,10 +176,12 @@ func (r *Runner) lockHeartbeatLoop() {
|
||||
}
|
||||
|
||||
func (r *Runner) releaseLock() {
|
||||
if r.lockStop != nil {
|
||||
close(r.lockStop)
|
||||
r.lockStop = nil
|
||||
}
|
||||
// Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
|
||||
r.lockStopOnce.Do(func() {
|
||||
if r.lockStop != nil {
|
||||
close(r.lockStop)
|
||||
}
|
||||
})
|
||||
if err := deleteRunLock(r.cfg.RunID); err != nil {
|
||||
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
|
||||
}
|
||||
@@ -297,15 +300,18 @@ func (r *Runner) stepOnce() error {
|
||||
if shouldDecide {
|
||||
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
|
||||
if err != nil {
|
||||
rec.Success = false
|
||||
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
||||
_ = r.logDecision(rec)
|
||||
// Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
|
||||
if rec != nil {
|
||||
rec.Success = false
|
||||
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
||||
_ = r.logDecision(rec)
|
||||
}
|
||||
return err
|
||||
}
|
||||
record = rec
|
||||
|
||||
var (
|
||||
fullDecision *decision.FullDecision
|
||||
fullDecision *kernel.FullDecision
|
||||
fromCache bool
|
||||
cacheKey string
|
||||
)
|
||||
@@ -470,7 +476,7 @@ func (r *Runner) stepOnce() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*decision.Context, *store.DecisionRecord, error) {
|
||||
func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*kernel.Context, *store.DecisionRecord, error) {
|
||||
equity, unrealized, _ := r.account.TotalEquity(priceMap)
|
||||
available := r.account.Cash()
|
||||
marginUsed := r.totalMarginUsed()
|
||||
@@ -479,7 +485,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
marginPct = (marginUsed / equity) * 100
|
||||
}
|
||||
|
||||
accountInfo := decision.AccountInfo{
|
||||
accountInfo := kernel.AccountInfo{
|
||||
TotalEquity: equity,
|
||||
AvailableBalance: available,
|
||||
TotalPnL: equity - r.account.InitialBalance(),
|
||||
@@ -495,14 +501,14 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
|
||||
if err != nil {
|
||||
// Fallback to simple list if strategy engine fails
|
||||
candidateCoins = make([]decision.CandidateCoin, 0, len(r.cfg.Symbols))
|
||||
candidateCoins = make([]kernel.CandidateCoin, 0, len(r.cfg.Symbols))
|
||||
for _, sym := range r.cfg.Symbols {
|
||||
candidateCoins = append(candidateCoins, decision.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
|
||||
candidateCoins = append(candidateCoins, kernel.CandidateCoin{Symbol: sym, Sources: []string{"backtest"}})
|
||||
}
|
||||
}
|
||||
|
||||
runtime := int((ts - int64(r.cfg.StartTS*1000)) / 60000)
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: runtime,
|
||||
CallCount: callCount,
|
||||
@@ -519,7 +525,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
|
||||
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
|
||||
strategyConfig := r.strategyEngine.GetConfig()
|
||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
||||
if strategyConfig.Indicators.EnableQuantData {
|
||||
// Collect symbols to query (candidate coins + position coins)
|
||||
symbolSet := make(map[string]bool)
|
||||
for _, sym := range r.cfg.Symbols {
|
||||
@@ -547,6 +553,24 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch NetFlow ranking data if enabled in strategy
|
||||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||||
ctx.NetFlowRankingData = r.strategyEngine.FetchNetFlowRankingData()
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
logger.Infof("💰 Backtest: NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||||
len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Price ranking data if enabled in strategy
|
||||
if strategyConfig.Indicators.EnablePriceRanking {
|
||||
ctx.PriceRankingData = r.strategyEngine.FetchPriceRankingData()
|
||||
if ctx.PriceRankingData != nil {
|
||||
logger.Infof("📈 Backtest: Price ranking data ready for %d durations",
|
||||
len(ctx.PriceRankingData.Durations))
|
||||
}
|
||||
}
|
||||
|
||||
record := &store.DecisionRecord{
|
||||
AccountState: store.AccountSnapshot{
|
||||
TotalBalance: accountInfo.TotalEquity,
|
||||
@@ -566,7 +590,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
||||
return ctx, record, nil
|
||||
}
|
||||
|
||||
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision.FullDecision) {
|
||||
func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *kernel.FullDecision) {
|
||||
record.InputPrompt = full.UserPrompt
|
||||
record.CoTTrace = full.CoTTrace
|
||||
if len(full.Decisions) > 0 {
|
||||
@@ -576,12 +600,12 @@ func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecision, error) {
|
||||
func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
||||
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
|
||||
// This ensures backtest uses the same unified prompt generation as live trading
|
||||
fd, err := decision.GetFullDecisionWithStrategy(
|
||||
fd, err := kernel.GetFullDecisionWithStrategy(
|
||||
ctx,
|
||||
r.mcpClient,
|
||||
r.strategyEngine,
|
||||
@@ -597,8 +621,12 @@ func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecisio
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
|
||||
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
|
||||
symbol := dec.Symbol
|
||||
if symbol == "" {
|
||||
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
|
||||
}
|
||||
|
||||
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
|
||||
actionRecord := store.DecisionAction{
|
||||
Action: dec.Action,
|
||||
@@ -607,9 +635,13 @@ func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]floa
|
||||
Timestamp: time.UnixMilli(ts).UTC(),
|
||||
}
|
||||
|
||||
basePrice := priceMap[symbol]
|
||||
if basePrice <= 0 {
|
||||
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s", symbol)
|
||||
if priceMap == nil {
|
||||
return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
|
||||
}
|
||||
|
||||
basePrice, ok := priceMap[symbol]
|
||||
if !ok || basePrice <= 0 {
|
||||
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
|
||||
}
|
||||
fillPrice := r.executionPrice(symbol, basePrice, ts)
|
||||
|
||||
@@ -739,7 +771,10 @@ func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]floa
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64 {
|
||||
// MinPositionSizeUSD is the minimum position size in USD to avoid dust positions
|
||||
const MinPositionSizeUSD = 10.0
|
||||
|
||||
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
|
||||
snapshot := r.snapshotState()
|
||||
equity := snapshot.Equity
|
||||
if equity <= 0 {
|
||||
@@ -770,6 +805,13 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
|
||||
sizeUSD = maxPositionValue
|
||||
}
|
||||
|
||||
// Reject positions below minimum size to avoid dust positions
|
||||
if sizeUSD < MinPositionSizeUSD {
|
||||
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
|
||||
sizeUSD, MinPositionSizeUSD)
|
||||
return 0
|
||||
}
|
||||
|
||||
qty := sizeUSD / price
|
||||
if qty < 0 {
|
||||
qty = 0
|
||||
@@ -777,7 +819,7 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
|
||||
return qty
|
||||
}
|
||||
|
||||
func (r *Runner) determineCloseQuantity(symbol, side string, dec decision.Decision) float64 {
|
||||
func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision) float64 {
|
||||
for _, pos := range r.account.Positions() {
|
||||
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
|
||||
return pos.Quantity
|
||||
@@ -787,20 +829,37 @@ func (r *Runner) determineCloseQuantity(symbol, side string, dec decision.Decisi
|
||||
}
|
||||
|
||||
func (r *Runner) resolveLeverage(requested int, symbol string) int {
|
||||
if requested > 0 {
|
||||
return requested
|
||||
}
|
||||
sym := strings.ToUpper(symbol)
|
||||
if sym == "BTCUSDT" || sym == "ETHUSDT" {
|
||||
if r.cfg.Leverage.BTCETHLeverage > 0 {
|
||||
return r.cfg.Leverage.BTCETHLeverage
|
||||
isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
|
||||
|
||||
// Determine configured max leverage for this symbol type
|
||||
var maxLeverage int
|
||||
if isBTCETH {
|
||||
maxLeverage = r.cfg.Leverage.BTCETHLeverage
|
||||
if maxLeverage <= 0 {
|
||||
maxLeverage = 10 // Default max for BTC/ETH
|
||||
}
|
||||
} else {
|
||||
if r.cfg.Leverage.AltcoinLeverage > 0 {
|
||||
return r.cfg.Leverage.AltcoinLeverage
|
||||
maxLeverage = r.cfg.Leverage.AltcoinLeverage
|
||||
if maxLeverage <= 0 {
|
||||
maxLeverage = 5 // Default max for altcoins
|
||||
}
|
||||
}
|
||||
return 5
|
||||
|
||||
// Use requested leverage if provided, otherwise use max as default
|
||||
leverage := requested
|
||||
if leverage <= 0 {
|
||||
leverage = maxLeverage
|
||||
}
|
||||
|
||||
// Enforce max leverage limit
|
||||
if leverage > maxLeverage {
|
||||
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
|
||||
leverage, maxLeverage, symbol)
|
||||
leverage = maxLeverage
|
||||
}
|
||||
|
||||
return leverage
|
||||
}
|
||||
|
||||
func (r *Runner) remainingPosition(symbol, side string) float64 {
|
||||
@@ -831,20 +890,26 @@ func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.Position
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *Runner) convertPositions(priceMap map[string]float64) []decision.PositionInfo {
|
||||
func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.PositionInfo {
|
||||
positions := r.account.Positions()
|
||||
list := make([]decision.PositionInfo, 0, len(positions))
|
||||
list := make([]kernel.PositionInfo, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
price := priceMap[pos.Symbol]
|
||||
list = append(list, decision.PositionInfo{
|
||||
pnl := unrealizedPnL(pos, price)
|
||||
// Calculate P&L percentage based on entry notional (position cost)
|
||||
pnlPct := 0.0
|
||||
if pos.Notional > 0 {
|
||||
pnlPct = (pnl / pos.Notional) * 100
|
||||
}
|
||||
list = append(list, kernel.PositionInfo{
|
||||
Symbol: pos.Symbol,
|
||||
Side: pos.Side,
|
||||
EntryPrice: pos.EntryPrice,
|
||||
MarkPrice: price,
|
||||
Quantity: pos.Quantity,
|
||||
Leverage: pos.Leverage,
|
||||
UnrealizedPnL: unrealizedPnL(pos, price),
|
||||
UnrealizedPnLPct: 0,
|
||||
UnrealizedPnL: pnl,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
LiquidationPrice: pos.LiquidationPrice,
|
||||
MarginUsed: pos.Margin,
|
||||
UpdateTime: time.Now().UnixMilli(),
|
||||
@@ -1416,7 +1481,7 @@ func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
|
||||
return positions
|
||||
}
|
||||
|
||||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||||
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||
if len(decisions) <= 1 {
|
||||
return decisions
|
||||
}
|
||||
@@ -1434,7 +1499,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]decision.Decision, len(decisions))
|
||||
result := make([]kernel.Decision, len(decisions))
|
||||
copy(result, decisions)
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
|
||||
@@ -17,17 +17,17 @@ func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
`), runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, os.ErrNotExist
|
||||
@@ -57,25 +57,25 @@ func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
`), runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
`), userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT config_json FROM backtest_runs WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,18 +96,18 @@ func saveRunMetadataDB(meta *RunMetadata) error {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
if _, err := persistenceDB.Exec(`
|
||||
if _, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
`), meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
`), userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -128,10 +128,10 @@ func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
||||
createdISO string
|
||||
updatedISO string
|
||||
)
|
||||
err := persistenceDB.QueryRow(`
|
||||
err := persistenceDB.QueryRow(convertQuery(`
|
||||
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
|
||||
FROM backtest_runs WHERE run_id = ?
|
||||
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
`), runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -183,18 +183,18 @@ func loadRunIDsDB() ([]string, error) {
|
||||
}
|
||||
|
||||
func appendEquityPointDB(runID string, point EquityPoint) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
`), runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -211,18 +211,18 @@ func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
}
|
||||
|
||||
func appendTradeEventDB(runID string, event TradeEvent) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
`), runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,17 +243,17 @@ func saveMetricsDB(runID string, metrics *Metrics) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
`), runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_metrics WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,22 +265,21 @@ func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
}
|
||||
|
||||
func saveProgressDB(runID string, payload progressPayload) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
`), payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if cycle > 0 {
|
||||
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY created_at DESC LIMIT 1`, runID, cycle)
|
||||
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
|
||||
} else {
|
||||
rows, err = persistenceDB.Query(query+` ORDER BY created_at DESC LIMIT 1`, runID)
|
||||
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY created_at DESC LIMIT 1`), runID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -308,20 +307,20 @@ func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`, runID, record.CycleNumber, data)
|
||||
`), runID, record.CycleNumber, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT payload FROM backtest_decisions
|
||||
WHERE run_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, runID, limit, offset)
|
||||
`), runID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -428,10 +427,10 @@ func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
|
||||
}
|
||||
|
||||
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT id, cycle, payload FROM backtest_decisions
|
||||
WHERE run_id = ? ORDER BY id ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -494,6 +493,6 @@ func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
||||
}
|
||||
|
||||
func deleteRunDB(runID string) error {
|
||||
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID)
|
||||
_, err := persistenceDB.Exec(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
233
cmd/lighter_test/main.go
Normal file
233
cmd/lighter_test/main.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Lighter API Authentication Test Tool
|
||||
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
|
||||
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
|
||||
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
|
||||
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
|
||||
flag.Parse()
|
||||
|
||||
if *walletAddr == "" || *apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" -wallet Ethereum wallet address (required)")
|
||||
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
|
||||
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
|
||||
fmt.Println(" -testnet Use testnet instead of mainnet")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Authentication Test ===")
|
||||
fmt.Printf("Wallet: %s\n", *walletAddr)
|
||||
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
|
||||
fmt.Printf("Testnet: %v\n", *testnet)
|
||||
fmt.Println()
|
||||
|
||||
// Determine base URL
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
if *testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(300)
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info
|
||||
fmt.Println("Step 1: Getting account info...")
|
||||
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
|
||||
|
||||
// Step 2: Create TxClient
|
||||
fmt.Println("Step 2: Creating TxClient...")
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
*apiKeyPrivateKey,
|
||||
accountInfo.AccountIndex,
|
||||
uint8(*apiKeyIndex),
|
||||
chainID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("SUCCESS: TxClient created\n")
|
||||
|
||||
// Step 3: Generate auth token
|
||||
fmt.Println("Step 3: Generating auth token...")
|
||||
deadline := time.Now().Add(1 * time.Hour)
|
||||
authToken, err := txClient.GetAuthToken(deadline)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Auth token generated\n")
|
||||
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
|
||||
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
|
||||
|
||||
// Step 4: Test GetActiveOrders API with auth query parameter
|
||||
fmt.Println("Step 4: Testing GetActiveOrders API...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("Response: %s\n\n", string(body))
|
||||
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
|
||||
fmt.Println("\n=== DIAGNOSTIC INFO ===")
|
||||
fmt.Println("If you see 'invalid signature', possible causes:")
|
||||
fmt.Println("1. API key is not registered on-chain")
|
||||
fmt.Println("2. API key private key is incorrect")
|
||||
fmt.Println("3. API key index is wrong")
|
||||
fmt.Println("4. Account index mismatch")
|
||||
fmt.Println("\nTo fix:")
|
||||
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
|
||||
fmt.Println("- Make sure you're using the correct API key private key")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
if i >= 5 {
|
||||
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
|
||||
}
|
||||
|
||||
// Step 5: Test GetTrades API (also needs auth)
|
||||
fmt.Println("\nStep 5: Testing GetTrades API...")
|
||||
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
|
||||
tradesResp, err := client.Do(tradesReq)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Trades request failed: %v\n", err)
|
||||
} else {
|
||||
defer tradesResp.Body.Close()
|
||||
tradesBody, _ := io.ReadAll(tradesResp.Body)
|
||||
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
|
||||
if tradesResp.StatusCode == 200 {
|
||||
fmt.Println("SUCCESS: GetTrades API working")
|
||||
} else {
|
||||
fmt.Printf("Response: %s\n", string(tradesBody))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== ALL TESTS PASSED ===")
|
||||
}
|
||||
|
||||
// AccountInfo represents Lighter account information
|
||||
type AccountInfo struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
L1Address string `json:"l1_address"`
|
||||
}
|
||||
|
||||
// getAccountByL1Address gets account info by L1 wallet address
|
||||
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response - can be in "accounts" or "sub_accounts" field
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
SubAccounts []AccountInfo `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// Check main accounts first
|
||||
if len(apiResp.Accounts) > 0 {
|
||||
return &apiResp.Accounts[0], nil
|
||||
}
|
||||
|
||||
// Check sub-accounts
|
||||
if len(apiResp.SubAccounts) > 0 {
|
||||
return &apiResp.SubAccounts[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
|
||||
}
|
||||
@@ -15,10 +15,8 @@ var global *Config
|
||||
// Only contains truly global config, trading related config is at trader/strategy level
|
||||
type Config struct {
|
||||
// Service configuration
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
RegistrationEnabled bool
|
||||
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 10)
|
||||
APIServerPort int
|
||||
JWTSecret string
|
||||
|
||||
// Database configuration
|
||||
DBType string // sqlite or postgres
|
||||
@@ -44,14 +42,13 @@ type Config struct {
|
||||
AlpacaAPIKey string // Alpaca API key for US stocks
|
||||
AlpacaSecretKey string // Alpaca secret key
|
||||
TwelveDataKey string // TwelveData API key for forex & metals
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
func Init() {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
RegistrationEnabled: true,
|
||||
MaxUsers: 10, // Default: 10 users allowed
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
// Database defaults
|
||||
DBType: "sqlite",
|
||||
@@ -71,16 +68,6 @@ func Init() {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
}
|
||||
|
||||
if v := os.Getenv("REGISTRATION_ENABLED"); v != "" {
|
||||
cfg.RegistrationEnabled = strings.ToLower(v) == "true"
|
||||
}
|
||||
|
||||
if v := os.Getenv("MAX_USERS"); v != "" {
|
||||
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
|
||||
cfg.MaxUsers = maxUsers
|
||||
}
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
||||
cfg.APIServerPort = port
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
// TraderExecutor interface for executing trades
|
||||
type TraderExecutor interface {
|
||||
ExecuteDecision(decision *decision.Decision) error
|
||||
ExecuteDecision(decision *kernel.Decision) error
|
||||
GetBalance() (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@ func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant
|
||||
client = mcp.NewGrokClient()
|
||||
case "kimi":
|
||||
client = mcp.NewKimiClient()
|
||||
case "minimax":
|
||||
client = mcp.NewMiniMaxClient()
|
||||
case "blockrun-base":
|
||||
client = mcp.NewBlockRunBaseClient()
|
||||
case "blockrun-sol":
|
||||
client = mcp.NewBlockRunSolClient()
|
||||
case "claw402":
|
||||
client = mcp.NewClaw402Client()
|
||||
default:
|
||||
client = mcp.New()
|
||||
}
|
||||
@@ -166,7 +174,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
||||
}()
|
||||
|
||||
// Create strategy engine for building context
|
||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||
|
||||
// Build market context using strategy config
|
||||
ctx, err := e.buildMarketContext(session, strategyEngine)
|
||||
@@ -289,7 +297,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
||||
}
|
||||
|
||||
// buildMarketContext builds the market context using strategy engine
|
||||
func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine) (*decision.Context, error) {
|
||||
func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine) (*kernel.Context, error) {
|
||||
config := strategyEngine.GetConfig()
|
||||
|
||||
// Get candidate coins
|
||||
@@ -335,12 +343,18 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := strategyEngine.FetchOIRankingData()
|
||||
|
||||
// Fetch NetFlow ranking data (market-wide fund flow)
|
||||
netFlowRankingData := strategyEngine.FetchNetFlowRankingData()
|
||||
|
||||
// Fetch Price ranking data (market-wide gainers/losers)
|
||||
priceRankingData := strategyEngine.FetchPriceRankingData()
|
||||
|
||||
// Build context
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: 0,
|
||||
CallCount: 1,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: 1000.0, // Simulated for debate
|
||||
AvailableBalance: 1000.0,
|
||||
UnrealizedPnL: 0,
|
||||
@@ -350,12 +364,14 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
||||
MarginUsedPct: 0,
|
||||
PositionCount: 0,
|
||||
},
|
||||
Positions: []decision.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: session.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
Positions: []kernel.PositionInfo{},
|
||||
CandidateCoins: candidates,
|
||||
PromptVariant: session.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
@@ -539,7 +555,7 @@ func (e *DebateEngine) getParticipantResponse(
|
||||
}
|
||||
|
||||
// collectVotes collects final votes from all participants
|
||||
func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) {
|
||||
func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) {
|
||||
var votes []*store.DebateVote
|
||||
|
||||
// Build voting context
|
||||
@@ -1009,7 +1025,7 @@ func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecuto
|
||||
}
|
||||
|
||||
// Create decision
|
||||
tradeDecision := &decision.Decision{
|
||||
tradeDecision := &kernel.Decision{
|
||||
Symbol: session.Symbol,
|
||||
Action: action,
|
||||
Leverage: session.FinalDecision.Leverage,
|
||||
|
||||
48
docker-compose.stable.yml
Normal file
48
docker-compose.stable.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
# NOFX Stable Release Deployment
|
||||
# Production-ready stable version
|
||||
|
||||
services:
|
||||
nofx:
|
||||
image: ghcr.io/nofxaios/nofx/nofx-backend:stable
|
||||
container_name: nofx-trading
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=${TZ:-Asia/Shanghai}
|
||||
- AI_MAX_TOKENS=8000
|
||||
networks:
|
||||
- nofx-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
nofx-frontend:
|
||||
image: ghcr.io/nofxaios/nofx/nofx-frontend:stable
|
||||
container_name: nofx-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NOFX_FRONTEND_PORT:-3000}:80"
|
||||
networks:
|
||||
- nofx-network
|
||||
depends_on:
|
||||
- nofx
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
nofx-network:
|
||||
driver: bridge
|
||||
852
docs/api/API_REFERENCE.md
Normal file
852
docs/api/API_REFERENCE.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# CryptoMaster API 接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
### 基础信息
|
||||
- **Base URL**: `https://nofxos.ai`
|
||||
- **响应格式**: JSON
|
||||
- **缓存时间**: 15秒(所有数据接口)
|
||||
- **限流**: 每个IP每秒最多30次请求
|
||||
|
||||
### 认证方式
|
||||
所有数据接口需要认证,支持两种方式:
|
||||
|
||||
#### 方式1: Query参数(推荐)
|
||||
```
|
||||
GET /api/ai500/list?auth=your_api_key
|
||||
```
|
||||
|
||||
#### 方式2: Authorization Header
|
||||
```
|
||||
GET /api/ai500/list
|
||||
Authorization: Bearer your_api_key
|
||||
```
|
||||
|
||||
### 响应格式
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重要:数值格式说明
|
||||
|
||||
### 百分比字段格式
|
||||
|
||||
不同接口的百分比字段使用不同的格式,请注意区分:
|
||||
|
||||
| 字段名 | 格式 | 示例 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `price_delta` (涨跌幅榜/币种详情) | **小数** | `0.05` = 5% | 需要 ×100 转换为百分比 |
|
||||
| `oi_delta_percent` | **已×100** | `5.0` = 5% | 直接使用,无需转换 |
|
||||
| `price_delta_percent` (OI接口) | **已×100** | `5.0` = 5% | 直接使用,无需转换 |
|
||||
| `increase_percent` (AI500) | **已×100** | `7.14` = 7.14% | 直接使用,无需转换 |
|
||||
|
||||
### 金额字段
|
||||
|
||||
| 字段名 | 单位 | 说明 |
|
||||
|--------|------|------|
|
||||
| `oi_delta_value` | USDT | 持仓价值变化 |
|
||||
| `amount` / `future_flow` / `spot_flow` | USDT | 资金流量 |
|
||||
| `price` | USDT | 当前价格 |
|
||||
|
||||
### 持仓量字段
|
||||
|
||||
| 字段名 | 单位 | 说明 |
|
||||
|--------|------|------|
|
||||
| `oi_delta` | 张/个 | 持仓量变化 |
|
||||
| `current_oi` / `oi` | 张/个 | 当前持仓量 |
|
||||
| `net_long` / `net_short` | 张/个 | 净多头/空头持仓 |
|
||||
|
||||
---
|
||||
|
||||
## 时间范围参数说明
|
||||
|
||||
所有接口支持的 `duration` 参数值:
|
||||
|
||||
| 参数值 | 说明 | 备注 |
|
||||
|--------|------|------|
|
||||
| `1m` | 1分钟 | |
|
||||
| `5m` | 5分钟 | |
|
||||
| `15m` | 15分钟 | |
|
||||
| `30m` | 30分钟 | |
|
||||
| `1h` | 1小时 | 默认值 |
|
||||
| `4h` | 4小时 | |
|
||||
| `8h` | 8小时 | |
|
||||
| `12h` | 12小时 | |
|
||||
| `24h` / `1d` | 24小时 | 两种写法均可 |
|
||||
| `2d` | 2天 | |
|
||||
| `3d` | 3天 | |
|
||||
| `5d` | 5天 | |
|
||||
| `7d` | 7天 | |
|
||||
|
||||
---
|
||||
|
||||
## 1. AI500 智能评分接口
|
||||
|
||||
AI500 是基于多维度量化指标的智能评分系统,用于筛选具有上涨潜力的币种。
|
||||
|
||||
### 1.1 获取AI500推荐币种列表
|
||||
|
||||
获取经过严格筛选的优质币种列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/list
|
||||
```
|
||||
|
||||
**过滤条件**
|
||||
- AI评分 > 70
|
||||
- 币安OI持仓价值 > 15M USDT
|
||||
- 现价 > 上榜起始价格(只返回上涨中的币种)
|
||||
- 资金没有持续流出(1h/4h/12h/24h不能全为负)
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 5,
|
||||
"coins": [
|
||||
{
|
||||
"pair": "BTCUSDT",
|
||||
"score": 85.234,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"last_score": 83.5,
|
||||
"max_score": 87.2,
|
||||
"max_price": 45000.0,
|
||||
"increase_percent": 7.14
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `pair` | string | 交易对名称,如 BTCUSDT |
|
||||
| `score` | float | 当前AI评分(0-100) |
|
||||
| `start_time` | int64 | 上榜时间戳(Unix秒) |
|
||||
| `start_price` | float | 上榜时价格(USDT) |
|
||||
| `last_score` | float | 上次记录的评分 |
|
||||
| `max_score` | float | 在榜期间最高评分 |
|
||||
| `max_price` | float | 在榜期间最高价格(USDT) |
|
||||
| `increase_percent` | float | 最大涨幅百分比(**已×100**,7.14 = 7.14%) |
|
||||
|
||||
---
|
||||
|
||||
### 1.2 获取单个币种AI500信息
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/:symbol
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `symbol` | string | 是 | 币种符号,支持 `BTCUSDT` 或 `BTC` 格式 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/ai500/BTC
|
||||
GET /api/ai500/ETHUSDT
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"info": {
|
||||
"pair": "BTCUSDT",
|
||||
"score": 85.234,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"last_score": 83.5,
|
||||
"max_score": 87.2,
|
||||
"max_price": 45000.0,
|
||||
"increase_percent": 7.14
|
||||
},
|
||||
"current_price": 44500.0,
|
||||
"score": 85.234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 获取AI500统计信息
|
||||
|
||||
获取AI500整体统计数据。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/ai500/stats
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"statistics": {
|
||||
"total_count": 50,
|
||||
"average_score": 72.5,
|
||||
"max_score": 95.2,
|
||||
"min_score": 55.3,
|
||||
"average_increase": 12.5
|
||||
},
|
||||
"top_coins": [...],
|
||||
"bottom_coins": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 持仓量(OI)排行接口
|
||||
|
||||
监控各币种的合约持仓量变化,用于判断市场资金动向。
|
||||
|
||||
### 2.1 获取OI增加排行榜
|
||||
|
||||
返回持仓价值增加最多的币种排行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/top-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `limit` | int | 20 | 返回数量,最大100 |
|
||||
| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/oi/top-ranking?limit=50&duration=4h
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 20,
|
||||
"exchange": "binance",
|
||||
"time_range": "4小时",
|
||||
"time_range_param": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 50,
|
||||
"positions": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"price": 44500.0,
|
||||
"oi_delta": 1500.5,
|
||||
"oi_delta_value": 65000000,
|
||||
"oi_delta_percent": 2.5,
|
||||
"current_oi": 62000,
|
||||
"price_delta_percent": 1.2,
|
||||
"net_long": 35000,
|
||||
"net_short": 27000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `rank` | int | - | 排名 |
|
||||
| `symbol` | string | - | 交易对名称 |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
| `oi_delta` | float | 张/个 | 持仓量变化 |
|
||||
| `oi_delta_value` | float | USDT | 持仓价值变化(**排序依据**) |
|
||||
| `oi_delta_percent` | float | **已×100** | 持仓量变化百分比,2.5 = 2.5% |
|
||||
| `current_oi` | float | 张/个 | 当前持仓量 |
|
||||
| `price_delta_percent` | float | **已×100** | 价格变化百分比,1.2 = 1.2% |
|
||||
| `net_long` | float | 张/个 | 净多头持仓 |
|
||||
| `net_short` | float | 张/个 | 净空头持仓 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 获取OI减少排行榜
|
||||
|
||||
返回持仓价值减少最多的币种排行。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/low-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
同 [OI增加排行榜](#21-获取oi增加排行榜)
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/oi/low-ranking?limit=30&duration=24h
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 获取OI Top20(向后兼容)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/oi/top
|
||||
```
|
||||
|
||||
固定返回1小时内OI增加最多的Top20,用于向后兼容。
|
||||
|
||||
---
|
||||
|
||||
## 3. 资金流量(NetFlow)排行接口
|
||||
|
||||
监控机构和散户的资金流向。
|
||||
|
||||
### 3.1 获取资金流入排行榜
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/top-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `limit` | int | 20 | 返回数量,最大100 |
|
||||
| `duration` | string | `1h` | 时间范围,见[时间范围参数](#时间范围参数说明) |
|
||||
| `type` | string | `institution` | 资金类型:`institution`(机构), `personal`(散户) |
|
||||
| `trade` | string | `future` | 交易类型:`future`(合约), `spot`(现货) |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/netflow/top-ranking?limit=30&duration=4h&type=institution&trade=future
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"count": 30,
|
||||
"type": "institution",
|
||||
"trade": "合约",
|
||||
"time_range": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 30,
|
||||
"netflows": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"amount": 15000000.5,
|
||||
"price": 44500.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `rank` | int | - | 排名 |
|
||||
| `symbol` | string | - | 交易对名称 |
|
||||
| `amount` | float | USDT | 资金流量,**正数=流入,负数=流出** |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 获取资金流出排行榜
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/low-ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
同 [资金流入排行榜](#31-获取资金流入排行榜)
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/netflow/low-ranking?limit=20&duration=1h&type=personal&trade=spot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 获取资金流入Top20(向后兼容)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/netflow/top
|
||||
```
|
||||
|
||||
固定返回1小时内机构合约资金流入最多的Top20。
|
||||
|
||||
---
|
||||
|
||||
## 4. 涨跌幅榜接口
|
||||
|
||||
### 4.1 获取涨跌幅榜
|
||||
|
||||
同时返回涨幅榜(top)和跌幅榜(low),支持多个时间周期同时查询。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/price/ranking
|
||||
```
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `duration` | string | `1h` | 时间范围,可多选逗号分隔:`1h,4h,24h` |
|
||||
| `limit` | int | 20 | 每个榜单返回数量,最大100 |
|
||||
| `exchange` | string | `binance` | 交易所 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/price/ranking?duration=1h,4h,24h&limit=20
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"durations": ["1h", "4h", "24h"],
|
||||
"limit": 20,
|
||||
"data": {
|
||||
"1h": {
|
||||
"top": [
|
||||
{
|
||||
"pair": "MOGUSDT",
|
||||
"symbol": "MOG",
|
||||
"price_delta": 0.0723,
|
||||
"price": 0.00123,
|
||||
"future_flow": 201500,
|
||||
"spot_flow": 0,
|
||||
"oi": 15000000,
|
||||
"oi_delta": 500000,
|
||||
"oi_delta_value": 615
|
||||
}
|
||||
],
|
||||
"low": [
|
||||
{
|
||||
"pair": "XYZUSDT",
|
||||
"symbol": "XYZ",
|
||||
"price_delta": -0.0512,
|
||||
"price": 1.234,
|
||||
"future_flow": -50000,
|
||||
"spot_flow": -10000,
|
||||
"oi": 8000000,
|
||||
"oi_delta": -200000,
|
||||
"oi_delta_value": -246800
|
||||
}
|
||||
]
|
||||
},
|
||||
"4h": { ... },
|
||||
"24h": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `pair` | string | - | 完整交易对名称,如 BTCUSDT |
|
||||
| `symbol` | string | - | 币种符号(去除USDT),如 BTC |
|
||||
| `price_delta` | float | **小数** | 价格变动比例,**0.0723 = 7.23%**(需×100显示) |
|
||||
| `price` | float | USDT | 当前价格 |
|
||||
| `future_flow` | float | USDT | 合约资金流量,正数=流入 |
|
||||
| `spot_flow` | float | USDT | 现货资金流量,正数=流入 |
|
||||
| `oi` | float | 张/个 | 当前持仓量 |
|
||||
| `oi_delta` | float | 张/个 | 持仓变化量 |
|
||||
| `oi_delta_value` | float | USDT | 持仓变化价值 |
|
||||
|
||||
> **注意**:`price_delta` 使用小数格式,与 OI 接口的 `price_delta_percent` 不同!
|
||||
|
||||
---
|
||||
|
||||
## 5. 币种详情接口
|
||||
|
||||
### 5.1 获取单币种完整数据
|
||||
|
||||
获取指定币种的所有统计信息,一次调用获取全部数据。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/coin/:symbol
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `symbol` | string | 是 | 币种符号,支持 `BTC` 或 `BTCUSDT` 格式 |
|
||||
|
||||
**查询参数**
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `include` | string | `netflow,oi,price,ai500` | 包含的数据类型,逗号分隔 |
|
||||
|
||||
**include 参数选项**
|
||||
| 值 | 说明 |
|
||||
|------|------|
|
||||
| `netflow` | 资金流量数据(机构/散户,合约/现货) |
|
||||
| `oi` | 持仓量数据(币安/Bybit) |
|
||||
| `price` | 价格变化数据 |
|
||||
| `ai500` | AI500评分 |
|
||||
|
||||
**示例**
|
||||
```
|
||||
GET /api/coin/BTC?include=netflow,oi,price,ai500
|
||||
GET /api/coin/ETHUSDT?include=netflow,oi
|
||||
```
|
||||
|
||||
**响应示例**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"symbol": "BTCUSDT",
|
||||
"price": 44500.0,
|
||||
"ai500": {
|
||||
"score": 85.234,
|
||||
"is_active": true,
|
||||
"start_time": 1704067200,
|
||||
"start_price": 42000.5,
|
||||
"increase_percent": 5.95
|
||||
},
|
||||
"netflow": {
|
||||
"institution": {
|
||||
"future": {
|
||||
"1m": 50000,
|
||||
"5m": 200000,
|
||||
"15m": 500000,
|
||||
"30m": 800000,
|
||||
"1h": 1500000,
|
||||
"4h": 5000000,
|
||||
"8h": 8000000,
|
||||
"12h": 10000000,
|
||||
"24h": 15000000,
|
||||
"2d": 25000000,
|
||||
"3d": 35000000,
|
||||
"5d": 50000000,
|
||||
"7d": 75000000
|
||||
},
|
||||
"spot": { ... }
|
||||
},
|
||||
"personal": {
|
||||
"future": { ... },
|
||||
"spot": { ... }
|
||||
}
|
||||
},
|
||||
"oi": {
|
||||
"binance": {
|
||||
"current_oi": 62000,
|
||||
"net_long": 35000,
|
||||
"net_short": 27000,
|
||||
"delta": {
|
||||
"1m": {
|
||||
"oi_delta": 50,
|
||||
"oi_delta_value": 2225000,
|
||||
"oi_delta_percent": 0.08
|
||||
},
|
||||
"5m": { ... },
|
||||
"1h": { ... },
|
||||
"4h": { ... },
|
||||
"24h": { ... }
|
||||
}
|
||||
},
|
||||
"bybit": { ... }
|
||||
},
|
||||
"price_change": {
|
||||
"1m": 0.001,
|
||||
"5m": 0.005,
|
||||
"15m": 0.008,
|
||||
"30m": 0.012,
|
||||
"1h": 0.015,
|
||||
"4h": 0.025,
|
||||
"8h": 0.035,
|
||||
"12h": 0.042,
|
||||
"24h": 0.055,
|
||||
"2d": 0.08,
|
||||
"3d": 0.12,
|
||||
"5d": 0.18,
|
||||
"7d": 0.25
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
**price_change 对象**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `{duration}` | float | **小数** | 价格变化比例,**0.015 = 1.5%**(需×100显示) |
|
||||
|
||||
**netflow 对象**
|
||||
| 路径 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `institution.future.{duration}` | float | USDT | 机构合约资金流量 |
|
||||
| `institution.spot.{duration}` | float | USDT | 机构现货资金流量 |
|
||||
| `personal.future.{duration}` | float | USDT | 散户合约资金流量 |
|
||||
| `personal.spot.{duration}` | float | USDT | 散户现货资金流量 |
|
||||
|
||||
**oi 对象**
|
||||
| 路径 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `binance.current_oi` | float | 张/个 | 币安当前持仓量 |
|
||||
| `binance.net_long` | float | 张/个 | 币安净多头 |
|
||||
| `binance.net_short` | float | 张/个 | 币安净空头 |
|
||||
| `binance.delta.{duration}.oi_delta` | float | 张/个 | 持仓量变化 |
|
||||
| `binance.delta.{duration}.oi_delta_value` | float | USDT | 持仓价值变化 |
|
||||
| `binance.delta.{duration}.oi_delta_percent` | float | **已×100** | 持仓变化百分比,0.08 = 0.08% |
|
||||
| `bybit.*` | - | - | Bybit数据,结构同上 |
|
||||
|
||||
**ai500 对象**
|
||||
| 字段 | 类型 | 格式 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `score` | float | 0-100 | AI综合评分 |
|
||||
| `is_active` | bool | - | 是否为活跃高分币种 |
|
||||
| `start_time` | int64 | Unix秒 | 上榜时间 |
|
||||
| `start_price` | float | USDT | 上榜时价格 |
|
||||
| `increase_percent` | float | **已×100** | 最大涨幅,5.95 = 5.95% |
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
|
||||
| HTTP状态码 | 说明 | 常见原因 |
|
||||
|------------|------|----------|
|
||||
| 200 | 成功 | - |
|
||||
| 400 | 请求参数错误 | 参数格式不正确、缺少必填参数 |
|
||||
| 401 | 未授权 | 缺少认证信息或API Key无效 |
|
||||
| 404 | 资源不存在 | 币种不存在或未被追踪 |
|
||||
| 429 | 请求过于频繁 | 超过限流阈值(30次/秒) |
|
||||
| 500 | 服务器内部错误 | 服务端异常 |
|
||||
|
||||
**错误响应示例**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
# 方式1: Query参数认证
|
||||
curl "https://nofxos.ai/api/ai500/list?auth=your_api_key"
|
||||
|
||||
# 方式2: Header认证
|
||||
curl "https://nofxos.ai/api/ai500/list" \
|
||||
-H "Authorization: Bearer your_api_key"
|
||||
|
||||
# 获取1小时涨跌幅榜
|
||||
curl "https://nofxos.ai/api/price/ranking?duration=1h&limit=20&auth=your_api_key"
|
||||
|
||||
# 获取多个时间周期涨跌幅榜
|
||||
curl "https://nofxos.ai/api/price/ranking?duration=1h,4h,24h&limit=10&auth=your_api_key"
|
||||
|
||||
# 获取BTC详细数据
|
||||
curl "https://nofxos.ai/api/coin/BTC?auth=your_api_key"
|
||||
|
||||
# 只获取BTC的资金流和OI数据
|
||||
curl "https://nofxos.ai/api/coin/BTC?include=netflow,oi&auth=your_api_key"
|
||||
|
||||
# 获取4小时OI增加排行Top50
|
||||
curl "https://nofxos.ai/api/oi/top-ranking?duration=4h&limit=50&auth=your_api_key"
|
||||
|
||||
# 获取24小时OI减少排行Top30
|
||||
curl "https://nofxos.ai/api/oi/low-ranking?duration=24h&limit=30&auth=your_api_key"
|
||||
|
||||
# 获取机构合约资金流入排行
|
||||
curl "https://nofxos.ai/api/netflow/top-ranking?type=institution&trade=future&duration=1h&auth=your_api_key"
|
||||
|
||||
# 获取散户现货资金流出排行
|
||||
curl "https://nofxos.ai/api/netflow/low-ranking?type=personal&trade=spot&duration=4h&auth=your_api_key"
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://nofxos.ai"
|
||||
API_KEY = "your_api_key"
|
||||
|
||||
# 方式1: Query参数认证
|
||||
def get_with_query_auth(endpoint, params=None):
|
||||
if params is None:
|
||||
params = {}
|
||||
params["auth"] = API_KEY
|
||||
response = requests.get(f"{BASE_URL}{endpoint}", params=params)
|
||||
return response.json()
|
||||
|
||||
# 方式2: Header认证
|
||||
def get_with_header_auth(endpoint, params=None):
|
||||
headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||
response = requests.get(f"{BASE_URL}{endpoint}", params=params, headers=headers)
|
||||
return response.json()
|
||||
|
||||
# 获取AI500列表
|
||||
def get_ai500_list():
|
||||
return get_with_query_auth("/api/ai500/list")
|
||||
|
||||
# 获取涨跌幅榜
|
||||
def get_price_ranking(durations="1h,4h,24h", limit=20):
|
||||
return get_with_query_auth("/api/price/ranking", {
|
||||
"duration": durations,
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
# 获取币种详情
|
||||
def get_coin_stats(symbol, include="netflow,oi,price,ai500"):
|
||||
return get_with_query_auth(f"/api/coin/{symbol}", {
|
||||
"include": include
|
||||
})
|
||||
|
||||
# 获取OI排行
|
||||
def get_oi_ranking(rank_type="top", duration="1h", limit=20):
|
||||
endpoint = f"/api/oi/{rank_type}-ranking"
|
||||
return get_with_query_auth(endpoint, {
|
||||
"duration": duration,
|
||||
"limit": limit
|
||||
})
|
||||
|
||||
# 获取资金流排行
|
||||
def get_netflow_ranking(rank_type="top", duration="1h", limit=20,
|
||||
flow_type="institution", trade="future"):
|
||||
endpoint = f"/api/netflow/{rank_type}-ranking"
|
||||
return get_with_query_auth(endpoint, {
|
||||
"duration": duration,
|
||||
"limit": limit,
|
||||
"type": flow_type,
|
||||
"trade": trade
|
||||
})
|
||||
|
||||
# 使用示例
|
||||
if __name__ == "__main__":
|
||||
# 获取AI500推荐币种
|
||||
ai500 = get_ai500_list()
|
||||
print(f"AI500推荐币种数量: {ai500['data']['count']}")
|
||||
|
||||
# 获取1小时涨幅榜前10
|
||||
ranking = get_price_ranking("1h", 10)
|
||||
for coin in ranking['data']['data']['1h']['top'][:3]:
|
||||
# 注意: price_delta 是小数,需要×100
|
||||
pct = coin['price_delta'] * 100
|
||||
print(f"{coin['symbol']}: {pct:.2f}%")
|
||||
|
||||
# 获取BTC详情
|
||||
btc = get_coin_stats("BTC")
|
||||
# 注意: price_change 是小数
|
||||
print(f"BTC 1小时涨跌: {btc['data']['price_change']['1h'] * 100:.2f}%")
|
||||
|
||||
# 获取4小时OI增加Top20
|
||||
oi = get_oi_ranking("top", "4h", 20)
|
||||
for pos in oi['data']['positions'][:3]:
|
||||
# 注意: oi_delta_percent 已×100
|
||||
print(f"{pos['symbol']}: OI变化 {pos['oi_delta_percent']:.2f}%")
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript 示例
|
||||
|
||||
```typescript
|
||||
const BASE_URL = "https://nofxos.ai";
|
||||
const API_KEY = "your_api_key";
|
||||
|
||||
// 通用请求函数
|
||||
async function apiRequest<T>(endpoint: string, params: Record<string, any> = {}): Promise<T> {
|
||||
const url = new URL(`${BASE_URL}${endpoint}`);
|
||||
params.auth = API_KEY;
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
});
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 获取涨跌幅榜
|
||||
interface PriceRankingItem {
|
||||
pair: string;
|
||||
symbol: string;
|
||||
price_delta: number; // 小数格式,0.05 = 5%
|
||||
price: number;
|
||||
future_flow: number;
|
||||
spot_flow: number;
|
||||
}
|
||||
|
||||
async function getPriceRanking(durations = "1h", limit = 20) {
|
||||
const data = await apiRequest<any>("/api/price/ranking", { duration: durations, limit });
|
||||
return data;
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
async function main() {
|
||||
const ranking = await getPriceRanking("1h,4h", 10);
|
||||
|
||||
for (const coin of ranking.data.data["1h"].top) {
|
||||
// 转换为百分比显示
|
||||
const pctChange = (coin.price_delta * 100).toFixed(2);
|
||||
console.log(`${coin.symbol}: ${pctChange}%`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么有些百分比字段格式不同?
|
||||
|
||||
A: 这是历史原因造成的:
|
||||
- **OI接口**的 `oi_delta_percent` 和 `price_delta_percent` 是**已乘100**的格式(5.0 = 5%)
|
||||
- **涨跌幅榜和币种详情**的 `price_delta` / `price_change` 是**小数**格式(0.05 = 5%)
|
||||
|
||||
建议在前端显示时统一处理。
|
||||
|
||||
### Q: duration 参数支持哪些值?
|
||||
|
||||
A: 支持以下值:`1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `8h`, `12h`, `24h`(或`1d`), `2d`, `3d`, `5d`, `7d`
|
||||
|
||||
### Q: 如何判断资金是流入还是流出?
|
||||
|
||||
A: `amount`、`future_flow`、`spot_flow` 等字段:
|
||||
- **正数** = 资金流入
|
||||
- **负数** = 资金流出
|
||||
|
||||
### Q: API缓存时间是多久?
|
||||
|
||||
A: 所有数据接口缓存15秒,相同请求在15秒内返回缓存数据。
|
||||
|
||||
### Q: 限流规则是什么?
|
||||
|
||||
A: 每个IP每秒最多30次请求,超过会返回 429 错误。
|
||||
@@ -1,350 +0,0 @@
|
||||
# 币种综合数据接口文档
|
||||
|
||||
## 接口概述
|
||||
|
||||
该接口提供单个币种的综合数据查询,一次请求即可获取资金净流入、持仓变化、价格变化等多维度数据。
|
||||
|
||||
## 请求信息
|
||||
|
||||
### 接口地址
|
||||
|
||||
```
|
||||
GET /api/coin/{symbol}
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数 | 位置 | 类型 | 必填 | 说明 |
|
||||
|-----|------|------|-----|------|
|
||||
| symbol | path | string | 是 | 币种符号,如 `PIPPINUSDT`、`ETH`(会自动补全USDT后缀) |
|
||||
| include | query | string | 否 | 返回数据类型,逗号分隔。可选值:`netflow,oi,price`。默认返回全部 |
|
||||
| auth | query | string | 是 | 认证密钥 |
|
||||
|
||||
### include 参数说明
|
||||
|
||||
| 值 | 说明 |
|
||||
|---|------|
|
||||
| netflow | 资金净流入数据(机构/散户、合约/现货) |
|
||||
| oi | 持仓数据(币安、Bybit) |
|
||||
| price | 价格变化百分比 |
|
||||
|
||||
---
|
||||
|
||||
## 返回数据
|
||||
|
||||
### 完整响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"symbol": "PIPPINUSDT",
|
||||
"price": 0.085,
|
||||
"netflow": {
|
||||
"institution": {
|
||||
"future": {
|
||||
"1m": 120000,
|
||||
"5m": 580000,
|
||||
"15m": 1200000,
|
||||
"30m": 2500000,
|
||||
"1h": 5800000,
|
||||
"4h": 12000000,
|
||||
"8h": 25000000,
|
||||
"12h": 38000000,
|
||||
"24h": 65000000,
|
||||
"2d": 120000000,
|
||||
"3d": 180000000
|
||||
},
|
||||
"spot": {
|
||||
"1m": 50000,
|
||||
"5m": 280000,
|
||||
"15m": 600000,
|
||||
"30m": 1200000,
|
||||
"1h": 2800000,
|
||||
"4h": 6000000,
|
||||
"8h": 12000000,
|
||||
"12h": 18000000,
|
||||
"24h": 32000000,
|
||||
"2d": 60000000,
|
||||
"3d": 90000000
|
||||
}
|
||||
},
|
||||
"personal": {
|
||||
"future": {
|
||||
"1m": -80000,
|
||||
"5m": -350000,
|
||||
"15m": -800000,
|
||||
"30m": -1500000,
|
||||
"1h": -3200000,
|
||||
"4h": -8000000,
|
||||
"8h": -15000000,
|
||||
"12h": -22000000,
|
||||
"24h": -40000000,
|
||||
"2d": -75000000,
|
||||
"3d": -110000000
|
||||
},
|
||||
"spot": {
|
||||
"1m": -30000,
|
||||
"5m": -150000,
|
||||
"15m": -400000,
|
||||
"30m": -800000,
|
||||
"1h": -1800000,
|
||||
"4h": -4000000,
|
||||
"8h": -8000000,
|
||||
"12h": -12000000,
|
||||
"24h": -22000000,
|
||||
"2d": -40000000,
|
||||
"3d": -60000000
|
||||
}
|
||||
}
|
||||
},
|
||||
"oi": {
|
||||
"binance": {
|
||||
"current_oi": 85000,
|
||||
"net_long": 48000,
|
||||
"net_short": 37000,
|
||||
"delta": {
|
||||
"1m": {
|
||||
"oi_delta": 150,
|
||||
"oi_delta_value": 14550000,
|
||||
"oi_delta_percent": 0.18
|
||||
},
|
||||
"5m": {
|
||||
"oi_delta": 680,
|
||||
"oi_delta_value": 65960000,
|
||||
"oi_delta_percent": 0.8
|
||||
},
|
||||
"1h": {
|
||||
"oi_delta": 2500,
|
||||
"oi_delta_value": 242500000,
|
||||
"oi_delta_percent": 2.94
|
||||
},
|
||||
"4h": {
|
||||
"oi_delta": 5200,
|
||||
"oi_delta_value": 504400000,
|
||||
"oi_delta_percent": 6.12
|
||||
},
|
||||
"24h": {
|
||||
"oi_delta": 8500,
|
||||
"oi_delta_value": 824500000,
|
||||
"oi_delta_percent": 10.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"bybit": {
|
||||
"current_oi": 42000,
|
||||
"net_long": 24000,
|
||||
"net_short": 18000,
|
||||
"delta": {
|
||||
"1h": {
|
||||
"oi_delta": 1200,
|
||||
"oi_delta_value": 116400000,
|
||||
"oi_delta_percent": 2.86
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"price_change": {
|
||||
"1m": 0.05,
|
||||
"5m": 0.18,
|
||||
"15m": 0.35,
|
||||
"30m": 0.62,
|
||||
"1h": 1.25,
|
||||
"4h": 2.80,
|
||||
"8h": 3.50,
|
||||
"12h": 2.95,
|
||||
"24h": 4.80,
|
||||
"2d": 6.50,
|
||||
"3d": 8.20
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 字段详细说明
|
||||
|
||||
### 基础字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| symbol | string | 币种交易对,如 `PIPPINUSDT` |
|
||||
| price | float | 当前期货价格(单位:USDT) |
|
||||
|
||||
---
|
||||
|
||||
### netflow - 资金净流入
|
||||
|
||||
资金净流入数据,**正数表示资金流入,负数表示资金流出**,单位为 USDT。
|
||||
|
||||
#### 数据结构
|
||||
|
||||
```
|
||||
netflow
|
||||
├── institution # 机构资金
|
||||
│ ├── future # 合约市场
|
||||
│ └── spot # 现货市场
|
||||
└── personal # 散户资金
|
||||
├── future # 合约市场
|
||||
└── spot # 现货市场
|
||||
```
|
||||
|
||||
#### 分类说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| institution.future | 机构在合约市场的资金净流入 |
|
||||
| institution.spot | 机构在现货市场的资金净流入 |
|
||||
| personal.future | 散户在合约市场的资金净流入 |
|
||||
| personal.spot | 散户在现货市场的资金净流入 |
|
||||
|
||||
#### 时间周期
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| 1m | 最近 1 分钟 |
|
||||
| 5m | 最近 5 分钟 |
|
||||
| 15m | 最近 15 分钟 |
|
||||
| 30m | 最近 30 分钟 |
|
||||
| 1h | 最近 1 小时 |
|
||||
| 4h | 最近 4 小时 |
|
||||
| 8h | 最近 8 小时 |
|
||||
| 12h | 最近 12 小时 |
|
||||
| 24h | 最近 24 小时 |
|
||||
| 2d | 最近 2 天 |
|
||||
| 3d | 最近 3 天 |
|
||||
|
||||
#### 使用建议
|
||||
|
||||
- **机构资金流入 + 散户资金流出** = 典型的主力吸筹信号
|
||||
- **机构资金流出 + 散户资金流入** = 典型的主力出货信号
|
||||
- 关注 **合约与现货的资金流向是否一致**,判断市场情绪
|
||||
|
||||
---
|
||||
|
||||
### oi - 持仓数据
|
||||
|
||||
持仓量(Open Interest)数据,来源于币安和 Bybit 交易所。
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| current_oi | float | 当前总持仓量(单位:币) |
|
||||
| net_long | float | 净多头持仓量 |
|
||||
| net_short | float | 净空头持仓量 |
|
||||
| delta | object | 各时间周期的持仓变化 |
|
||||
|
||||
#### delta 子字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| oi_delta | float | 持仓量变化(单位:币) |
|
||||
| oi_delta_value | float | 持仓价值变化(单位:USDT) |
|
||||
| oi_delta_percent | float | 持仓量变化百分比(%) |
|
||||
|
||||
#### 使用建议
|
||||
|
||||
- **持仓量增加 + 价格上涨** = 多头主导,趋势可能延续
|
||||
- **持仓量增加 + 价格下跌** = 空头主导,下跌趋势可能延续
|
||||
- **持仓量减少 + 价格变化** = 平仓为主,趋势可能反转
|
||||
- **net_long > net_short** = 市场整体偏多
|
||||
|
||||
---
|
||||
|
||||
### price_change - 价格变化
|
||||
|
||||
各时间周期的价格涨跌幅,**单位为百分比(%)**,正数表示上涨,负数表示下跌。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|-----|------|
|
||||
| 1m | 最近 1 分钟涨跌幅 |
|
||||
| 5m | 最近 5 分钟涨跌幅 |
|
||||
| 15m | 最近 15 分钟涨跌幅 |
|
||||
| 30m | 最近 30 分钟涨跌幅 |
|
||||
| 1h | 最近 1 小时涨跌幅 |
|
||||
| 4h | 最近 4 小时涨跌幅 |
|
||||
| 8h | 最近 8 小时涨跌幅 |
|
||||
| 12h | 最近 12 小时涨跌幅 |
|
||||
| 24h | 最近 24 小时涨跌幅 |
|
||||
| 2d | 最近 2 天涨跌幅 |
|
||||
| 3d | 最近 3 天涨跌幅 |
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 400 | 参数错误(如缺少 symbol) |
|
||||
| 401 | 认证失败(auth 无效) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
错误响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "symbol parameter is required"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X GET "http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://nofxaios.com:30006/api/coin/PIPPINUSDT"
|
||||
params = {
|
||||
"include": "netflow,oi,price",
|
||||
"auth": "cm_568c67eae410d912c54c"
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
print(f"当前价格: {data['data']['price']}")
|
||||
print(f"1小时机构合约净流入: {data['data']['netflow']['institution']['future']['1h']}")
|
||||
print(f"24小时价格涨跌幅: {data['data']['price_change']['24h']}%")
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const url = 'http://nofxaios.com:30006/api/coin/PIPPINUSDT?include=netflow,oi,price&auth=cm_568c67eae410d912c54c';
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('当前价格:', data.data.price);
|
||||
console.log('1小时机构合约净流入:', data.data.netflow.institution.future['1h']);
|
||||
console.log('24小时价格涨跌幅:', data.data.price_change['24h'], '%');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **symbol 参数**:支持带或不带 `USDT` 后缀,如 `PIPPIN` 和 `PIPPINUSDT` 等效
|
||||
2. **include 参数**:可按需选择返回数据,减少不必要的数据传输
|
||||
3. **数据更新频率**:数据实时更新,建议轮询间隔不低于 1 秒
|
||||
4. **资金流向解读**:机构与散户的资金流向通常呈相反趋势,可作为市场情绪判断依据
|
||||
@@ -1,254 +0,0 @@
|
||||
# OI 持仓数据接口文档
|
||||
|
||||
## 接口概述
|
||||
|
||||
该接口提供币安交易所的合约持仓量(Open Interest)排行数据,支持查询持仓增加和减少排行榜。
|
||||
|
||||
## 接口列表
|
||||
|
||||
| 接口 | 说明 |
|
||||
|-----|------|
|
||||
| `/api/oi/top` | 持仓增加排行 Top20(固定参数,向后兼容) |
|
||||
| `/api/oi/top-ranking` | 持仓增加排行(支持自定义参数) |
|
||||
| `/api/oi/low-ranking` | 持仓减少排行(支持自定义参数) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 持仓增加排行 Top20
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/top
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/top?auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| auth | string | 是 | 认证密钥 |
|
||||
|
||||
### 说明
|
||||
|
||||
固定返回 1 小时内持仓价值增加最多的前 20 个币种,向后兼容接口。
|
||||
|
||||
---
|
||||
|
||||
## 2. 持仓增加排行(自定义参数)
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/top-ranking
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|-----|------|-----|-------|------|
|
||||
| limit | int | 否 | 20 | 获取数量,范围 1-100 |
|
||||
| duration | string | 否 | 1h | 时间范围 |
|
||||
| auth | string | 是 | - | 认证密钥 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 持仓减少排行
|
||||
|
||||
### 请求
|
||||
|
||||
```
|
||||
GET /api/oi/low-ranking
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```
|
||||
http://nofxaios.com:30006/api/oi/low-ranking?limit=30&duration=24h&auth=cm_568c67eae410d912c54c
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
同持仓增加排行接口。
|
||||
|
||||
---
|
||||
|
||||
## duration 时间范围参数
|
||||
|
||||
| 值 | 说明 |
|
||||
|---|------|
|
||||
| 1m | 1 分钟 |
|
||||
| 5m | 5 分钟 |
|
||||
| 15m | 15 分钟 |
|
||||
| 30m | 30 分钟 |
|
||||
| 1h | 1 小时(默认) |
|
||||
| 4h | 4 小时 |
|
||||
| 8h | 8 小时 |
|
||||
| 12h | 12 小时 |
|
||||
| 24h | 24 小时 |
|
||||
| 1d | 1 天(同 24h) |
|
||||
| 2d | 2 天 |
|
||||
| 3d | 3 天 |
|
||||
|
||||
---
|
||||
|
||||
## 返回数据
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"count": 20,
|
||||
"exchange": "binance",
|
||||
"time_range": "4小时",
|
||||
"time_range_param": "4h",
|
||||
"rank_type": "top",
|
||||
"limit": 20,
|
||||
"positions": [
|
||||
{
|
||||
"rank": 1,
|
||||
"symbol": "BTCUSDT",
|
||||
"oi_delta": 1500.5,
|
||||
"oi_delta_value": 145500000,
|
||||
"oi_delta_percent": 3.52,
|
||||
"current_oi": 44000,
|
||||
"price_delta_percent": 2.15,
|
||||
"net_long": 26000,
|
||||
"net_short": 18000
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"symbol": "ETHUSDT",
|
||||
"oi_delta": 25000,
|
||||
"oi_delta_value": 87500000,
|
||||
"oi_delta_percent": 2.85,
|
||||
"current_oi": 900000,
|
||||
"price_delta_percent": 1.80,
|
||||
"net_long": 520000,
|
||||
"net_short": 380000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
#### 外层字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| count | int | 返回的币种数量 |
|
||||
| exchange | string | 交易所,固定为 `binance` |
|
||||
| time_range | string | 时间范围显示名称 |
|
||||
| time_range_param | string | 时间范围参数值 |
|
||||
| rank_type | string | 排行类型:`top` 增加 / `low` 减少 |
|
||||
| limit | int | 请求的数量限制 |
|
||||
| positions | array | 持仓数据列表 |
|
||||
|
||||
#### positions 数组字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| rank | int | 排名 |
|
||||
| symbol | string | 币种交易对,如 `BTCUSDT` |
|
||||
| oi_delta | float | 持仓量变化(单位:币) |
|
||||
| oi_delta_value | float | 持仓价值变化(单位:USDT),**排序依据** |
|
||||
| oi_delta_percent | float | 持仓量变化百分比(%) |
|
||||
| current_oi | float | 当前持仓量(单位:币) |
|
||||
| price_delta_percent | float | 价格变化百分比(%) |
|
||||
| net_long | float | 净多头持仓量 |
|
||||
| net_short | float | 净空头持仓量 |
|
||||
|
||||
---
|
||||
|
||||
## 数据解读
|
||||
|
||||
### 持仓量与价格的关系
|
||||
|
||||
| 持仓变化 | 价格变化 | 市场含义 |
|
||||
|---------|---------|---------|
|
||||
| 增加 | 上涨 | 多头主导,上涨趋势可能延续 |
|
||||
| 增加 | 下跌 | 空头主导,下跌趋势可能延续 |
|
||||
| 减少 | 上涨 | 空头平仓,可能是反弹 |
|
||||
| 减少 | 下跌 | 多头平仓,可能是回调 |
|
||||
|
||||
### 多空比例
|
||||
|
||||
- `net_long > net_short`:市场整体偏多
|
||||
- `net_long < net_short`:市场整体偏空
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
curl -X GET "http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://nofxaios.com:30006/api/oi/top-ranking"
|
||||
params = {
|
||||
"limit": 50,
|
||||
"duration": "4h",
|
||||
"auth": "cm_568c67eae410d912c54c"
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
for pos in data['data']['positions']:
|
||||
print(f"#{pos['rank']} {pos['symbol']}: 持仓价值变化 ${pos['oi_delta_value']:,.0f}")
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const url = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=50&duration=4h&auth=cm_568c67eae410d912c54c';
|
||||
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
data.data.positions.forEach(pos => {
|
||||
console.log(`#${pos.rank} ${pos.symbol}: 持仓价值变化 $${pos.oi_delta_value.toLocaleString()}`);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 401 | 认证失败(auth 无效) |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 数据来源为币安交易所
|
||||
2. 排行依据为 `oi_delta_value`(持仓价值变化),非持仓量变化
|
||||
3. 数据缓存 2 秒,高频请求会命中缓存
|
||||
4. `limit` 最大值为 100
|
||||
@@ -112,7 +112,7 @@ func (e *StrategyEngine) getCoinPoolCoins(limit int) []CandidateCoin {
|
||||
}
|
||||
```
|
||||
|
||||
- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `http://nofxaios.com:30006/api/ai500/list`)
|
||||
- **API:** `config.CoinSource.CoinPoolAPIURL` (默认: `https://nofxos.ai/api/ai500/list`)
|
||||
- **用途:** 获取 AI 评分最高的 N 个币种
|
||||
- **标签:** `["ai500"]`
|
||||
|
||||
|
||||
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
50
docs/community/OFFICIAL_ACCOUNTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ⚠️ Official Accounts & Anti-Impersonation Notice
|
||||
|
||||
## Legal Entity
|
||||
|
||||
| Field | Details |
|
||||
|-------|---------|
|
||||
| Company Name | **Cryonic Holdings Limited** |
|
||||
| Company No. | 2193977 |
|
||||
| Jurisdiction | British Virgin Islands |
|
||||
| Address | Mandar House, 3rd Floor, P.O. Box 2196, Johnson's Ghut, Tortola, BVI |
|
||||
| Contact Email | 0xccfelix@gmail.com |
|
||||
|
||||
## Official Social Media & Channels
|
||||
|
||||
| Platform | Official Account | Link | Status |
|
||||
|----------|-----------------|------|--------|
|
||||
| Twitter/X | **@nofx_official** | https://x.com/nofx_official | ✅ Official |
|
||||
| Twitter/X | **@Web3Tinkle** | https://x.com/Web3Tinkle | ✅ Founder |
|
||||
| GitHub | **NoFxAiOS** | https://github.com/NoFxAiOS | ✅ Official |
|
||||
| Website | **nofxai.com** | https://nofxai.com | ✅ Official |
|
||||
| Dashboard | **nofxos.ai** | https://nofxos.ai | ✅ Official |
|
||||
|
||||
## ⛔ Known Impersonation Accounts
|
||||
|
||||
The following accounts are **NOT affiliated** with the NoFx project:
|
||||
|
||||
| Platform | Account | Status |
|
||||
|----------|---------|--------|
|
||||
| Twitter/X | @nofx_ai | ❌ **NOT OFFICIAL** — Not affiliated with this project |
|
||||
|
||||
> **Warning:** Any account claiming to represent NoFx that is not listed above is unauthorized. Please verify through this page before trusting any account claiming to be associated with NoFx.
|
||||
|
||||
## How to Verify Authenticity
|
||||
|
||||
1. Check this page (OFFICIAL_ACCOUNTS.md) in our official GitHub repository
|
||||
2. Our GitHub repository sidebar links directly to our official Twitter
|
||||
3. Our README.md lists all official accounts under "Core Team" and "Official Links"
|
||||
4. Our operating entity is Cryonic Holdings Limited (BVI No. 2193977)
|
||||
5. Official contact email: 0xccfelix@gmail.com
|
||||
|
||||
## Report Impersonation
|
||||
|
||||
If you encounter accounts impersonating NoFx, please:
|
||||
1. Report them on the respective platform
|
||||
2. Open an issue in this repository to notify our team
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-01*
|
||||
*This document is maintained by Cryonic Holdings Limited in the official NoFx GitHub repository (10,500+ ⭐)*
|
||||
@@ -241,6 +241,7 @@ NOFX offers bounties for valuable contributions:
|
||||
- **Want to claim bounty?** → [Bounty Guide](bounty-guide.md)
|
||||
- **Found a security issue?** → [Security Policy](../../SECURITY.md)
|
||||
- **Have questions?** → [Telegram Community](https://t.me/nofx_dev_community)
|
||||
- **Verify official accounts?** → [Official Accounts & Anti-Impersonation](OFFICIAL_ACCOUNTS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,19 @@ Use custom AI models or third-party OpenAI-compatible APIs:
|
||||
|
||||
---
|
||||
|
||||
### 💳 BlockRun Wallet (Pay-per-Request, No API Key)
|
||||
|
||||
Access all top AI models by paying with USDC — no API key signup required.
|
||||
|
||||
| Provider | Guide | Payment Network |
|
||||
|----------|-------|-----------------|
|
||||
| BlockRun (Base Wallet) | [blockrun-base-wallet.md](blockrun-base-wallet.md) | Base (EVM) · USDC |
|
||||
| BlockRun (Solana Wallet) | [blockrun-sol-wallet.md](blockrun-sol-wallet.md) | Solana · USDC |
|
||||
|
||||
**How it works:** Each AI request automatically pays a micro-USDC fee via the [x402 payment protocol](https://blockrun.ai). Your private key signs the payment authorization — no funds leave your wallet until the AI response is delivered.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Prerequisites
|
||||
|
||||
Before starting, ensure you have:
|
||||
|
||||
126
docs/getting-started/blockrun-base-wallet.md
Normal file
126
docs/getting-started/blockrun-base-wallet.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# BlockRun Base (EVM) Wallet Setup Guide
|
||||
|
||||
This guide explains how to use a Base network EVM wallet to pay for AI usage through BlockRun — no API key required.
|
||||
|
||||
**Language:** [English](blockrun-base-wallet.md) | [中文](blockrun-base-wallet.zh-CN.md)
|
||||
|
||||
## What is BlockRun?
|
||||
|
||||
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
|
||||
|
||||
NOFX integrates BlockRun via the **x402 micropayment protocol**: each AI inference request automatically pays a small USDC fee directly from your wallet. You only pay for what you use.
|
||||
|
||||
## Why Use BlockRun?
|
||||
|
||||
| Feature | Traditional API Key | BlockRun Wallet |
|
||||
|---------|-------------------|-----------------|
|
||||
| Setup | Register + billing | Just a wallet address |
|
||||
| Cost model | Monthly subscription | Pay-per-request |
|
||||
| Models | One provider | All top models |
|
||||
| Privacy | Account required | Pseudonymous |
|
||||
| Control | Rate limits apply | Your wallet, your budget |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An EVM wallet with USDC on **Base network** (chain ID 8453)
|
||||
- The wallet private key (hex format: `0x...`)
|
||||
|
||||
### Getting USDC on Base
|
||||
|
||||
1. Buy USDC on Coinbase and withdraw to Base, **or**
|
||||
2. Bridge USDC from Ethereum using [bridge.base.org](https://bridge.base.org), **or**
|
||||
3. Swap on [Aerodrome](https://aerodrome.finance) or [Uniswap](https://app.uniswap.org) on Base
|
||||
|
||||
> **Tip:** A few dollars of USDC is enough to start — each AI call costs fractions of a cent.
|
||||
|
||||
## Step 1: Get Your Wallet Private Key
|
||||
|
||||
> ⚠️ **Security Warning:** Never share your private key with anyone. Use a dedicated trading wallet, not your main holdings wallet.
|
||||
|
||||
**Option A — Create a new wallet (recommended):**
|
||||
1. Open MetaMask → Create New Account
|
||||
2. Go to Account Details → Export Private Key
|
||||
3. Copy the hex key (starts with `0x`)
|
||||
|
||||
**Option B — Use an existing wallet:**
|
||||
1. MetaMask → Account Details → Export Private Key
|
||||
2. Enter your MetaMask password to reveal the key
|
||||
|
||||
**Option C — Generate via CLI:**
|
||||
```bash
|
||||
# Using cast (foundry)
|
||||
cast wallet new
|
||||
# Output: Address: 0x... | Private key: 0x...
|
||||
```
|
||||
|
||||
## Step 2: Fund the Wallet with USDC on Base
|
||||
|
||||
Send USDC to your wallet address on Base network:
|
||||
- **USDC contract:** `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`
|
||||
- **Network:** Base (chain ID 8453)
|
||||
- **Recommended starting amount:** $5–$20 USDC
|
||||
|
||||
Check your balance at [basescan.org](https://basescan.org).
|
||||
|
||||
## Step 3: Configure in NOFX
|
||||
|
||||
1. Open NOFX at `http://localhost:3000`
|
||||
2. Log in and go to **Config** tab
|
||||
3. Click **+ Add AI Model**
|
||||
4. In Step 0, scroll to **Via BlockRun Wallet** section
|
||||
5. Select **BlockRun · Base Wallet**
|
||||
6. In Step 1, configure:
|
||||
- **Wallet Private Key:** Your hex private key (`0x...`)
|
||||
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
|
||||
7. Click **Save**
|
||||
|
||||
## How Payment Works
|
||||
|
||||
When NOFX sends an AI request:
|
||||
|
||||
1. Request goes to `https://blockrun.ai/api/v1/chat/completions`
|
||||
2. Server responds with HTTP `402 Payment Required` + payment details
|
||||
3. NOFX signs a **ERC-3009 TransferWithAuthorization** (EIP-712) with your private key
|
||||
4. Payment signature is attached and request is retried
|
||||
5. BlockRun verifies the signature, routes the request to the AI model, and charges USDC
|
||||
|
||||
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
|
||||
|
||||
## Available Models via BlockRun
|
||||
|
||||
| Model ID | Provider | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `gpt-5.4` | OpenAI | Flagship (default) |
|
||||
| `claude-opus-4.6` | Anthropic | Flagship |
|
||||
| `gemini-3.1-pro` | Google | Flagship |
|
||||
| `grok-3` | xAI | Flagship |
|
||||
| `deepseek-chat` | DeepSeek | Flagship |
|
||||
| `minimax-m2.5` | MiniMax | Flagship |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- ✅ Use a **dedicated wallet** with only trading budget, not your main wallet
|
||||
- ✅ Keep only a small USDC balance (top up as needed)
|
||||
- ✅ Your private key is encrypted at rest in NOFX's database
|
||||
- ✅ Signatures are spend-limited — each signature authorizes only the exact amount for one request
|
||||
- ❌ Never export or share your private key outside of NOFX
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `no private key set` | Check your key was saved correctly; re-enter in Config |
|
||||
| `payment retry failed` | Ensure you have USDC on **Base** (not Ethereum mainnet) |
|
||||
| `invalid private key` | Key must be hex format with `0x` prefix, 66 chars total |
|
||||
| Payment deducted but no response | Check BlockRun status at [blockrun.ai](https://blockrun.ai) |
|
||||
| Slow responses | Try selecting a specific model instead of "Auto" |
|
||||
|
||||
## Monitoring Spend
|
||||
|
||||
Check your USDC balance and transaction history at:
|
||||
- [Basescan](https://basescan.org) — search your wallet address
|
||||
- [BlockRun dashboard](https://blockrun.ai) — usage history
|
||||
|
||||
---
|
||||
|
||||
[← Back to Getting Started](README.md)
|
||||
120
docs/getting-started/blockrun-sol-wallet.md
Normal file
120
docs/getting-started/blockrun-sol-wallet.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# BlockRun Solana Wallet Setup Guide
|
||||
|
||||
This guide explains how to use a Solana wallet to pay for AI usage through BlockRun — no API key required.
|
||||
|
||||
**Language:** [English](blockrun-sol-wallet.md) | [中文](blockrun-sol-wallet.zh-CN.md)
|
||||
|
||||
## What is BlockRun?
|
||||
|
||||
[BlockRun](https://blockrun.ai) is a decentralized AI inference gateway that lets you access top AI models (Claude, GPT, Gemini, Grok, DeepSeek, etc.) by paying per request with USDC — no monthly subscriptions, no API key signups.
|
||||
|
||||
NOFX integrates BlockRun via the **x402 micropayment protocol** on Solana: each AI inference request automatically pays a small USDC fee directly from your wallet.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Solana wallet with USDC on **Solana mainnet**
|
||||
- The wallet private key (base58-encoded, 64 bytes — standard Solana keypair format)
|
||||
|
||||
### Getting USDC on Solana
|
||||
|
||||
1. Buy SOL on any exchange and withdraw to your Solana wallet, then swap to USDC on [Jupiter](https://jup.ag), **or**
|
||||
2. Buy USDC directly on an exchange and withdraw to Solana, **or**
|
||||
3. Bridge from other chains using [Wormhole](https://wormhole.com)
|
||||
|
||||
> **Tip:** A few dollars of USDC is plenty to start.
|
||||
|
||||
## Step 1: Export Your Solana Private Key
|
||||
|
||||
> ⚠️ **Security Warning:** Use a dedicated wallet for NOFX — not your main holdings wallet.
|
||||
|
||||
**From Phantom Wallet:**
|
||||
1. Open Phantom → Settings (gear icon)
|
||||
2. Security & Privacy → Export Private Key
|
||||
3. Enter your password
|
||||
4. Copy the base58 key (looks like: `5J...` — a long string of ~88 characters)
|
||||
|
||||
**From Solflare:**
|
||||
1. Settings → Export Private Key
|
||||
2. The key is displayed in base58 format
|
||||
|
||||
**From CLI (solana-keygen):**
|
||||
```bash
|
||||
# View existing keypair
|
||||
cat ~/.config/solana/id.json
|
||||
# This is a JSON array — convert to base58 using:
|
||||
solana-keygen pubkey ~/.config/solana/id.json
|
||||
```
|
||||
|
||||
> **Note:** NOFX accepts the **base58-encoded 64-byte keypair** (as exported by Phantom/Solflare). This is the standard format for Solana private keys.
|
||||
|
||||
## Step 2: Fund the Wallet with USDC on Solana
|
||||
|
||||
Send USDC to your Solana wallet:
|
||||
- **USDC SPL token mint:** `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
|
||||
- **Network:** Solana Mainnet
|
||||
- **Recommended starting amount:** $5–$20 USDC
|
||||
|
||||
Check your balance at [solscan.io](https://solscan.io) or in your wallet app.
|
||||
|
||||
## Step 3: Configure in NOFX
|
||||
|
||||
1. Open NOFX at `http://localhost:3000`
|
||||
2. Log in and go to **Config** tab
|
||||
3. Click **+ Add AI Model**
|
||||
4. In Step 0, scroll to **Via BlockRun Wallet** section
|
||||
5. Select **BlockRun · Solana Wallet**
|
||||
6. In Step 1, configure:
|
||||
- **Wallet Private Key:** Your base58-encoded Solana private key
|
||||
- **Select Model:** Choose from Claude Opus, GPT-5.4, Gemini 3 Pro, Grok 3, DeepSeek R1, or leave as **Auto** for best available
|
||||
7. Click **Save**
|
||||
|
||||
## How Payment Works
|
||||
|
||||
When NOFX sends an AI request:
|
||||
|
||||
1. Request goes to `https://sol.blockrun.ai/api/v1/chat/completions`
|
||||
2. Server responds with HTTP `402 Payment Required` + payment details (nonce, recipient, amount)
|
||||
3. NOFX signs the payment message `blockrun-payment:{nonce}:{recipient}:{amount}` with your **Ed25519** private key
|
||||
4. Payment signature is attached and request is retried
|
||||
5. BlockRun verifies the Ed25519 signature on-chain and routes to the AI model
|
||||
|
||||
> **Privacy:** Your private key never leaves your NOFX instance. Only the cryptographic signature is sent.
|
||||
|
||||
## Available Models via BlockRun
|
||||
|
||||
| Model ID | Provider | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `gpt-5.4` | OpenAI | Flagship (default) |
|
||||
| `claude-opus-4.6` | Anthropic | Flagship |
|
||||
| `gemini-3.1-pro` | Google | Flagship |
|
||||
| `grok-3` | xAI | Flagship |
|
||||
| `deepseek-chat` | DeepSeek | Flagship |
|
||||
| `minimax-m2.5` | MiniMax | Flagship |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
- ✅ Use a **dedicated trading wallet** with only your AI budget
|
||||
- ✅ Keep only a small USDC balance (top up as needed)
|
||||
- ✅ Your private key is AES-256 encrypted at rest in NOFX's database
|
||||
- ✅ Ed25519 signatures are one-time — each authorizes only one specific payment
|
||||
- ❌ Never use your main SOL holdings wallet as the NOFX trading wallet
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `unexpected key length` | Ensure you exported the full 64-byte keypair (not just the 32-byte seed) |
|
||||
| `failed to decode base58` | Key must be base58 encoded (standard Phantom/Solflare export format) |
|
||||
| `payment retry failed` | Ensure you have USDC on **Solana mainnet** (not devnet) |
|
||||
| No response from server | Check `sol.blockrun.ai` is reachable from your server |
|
||||
| Slow responses | Try selecting a specific model instead of "Auto" |
|
||||
|
||||
## Monitoring Spend
|
||||
|
||||
Check your USDC balance and transaction history at:
|
||||
- [Solscan](https://solscan.io) — search your wallet address, filter by USDC token
|
||||
- [BlockRun dashboard](https://blockrun.ai) — usage history
|
||||
|
||||
---
|
||||
|
||||
[← Back to Getting Started](README.md)
|
||||
@@ -1,27 +1,41 @@
|
||||
# NOFX - AI トレーディングシステム
|
||||
<h1 align="center">NOFX — オープンソース AI トレーディング OS</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>AI 駆動金融取引のインフラストラクチャレイヤー</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
**言語:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [日本語](README.md)
|
||||
|
||||
---
|
||||
|
||||
## AI 駆動の暗号通貨取引プラットフォーム
|
||||
|
||||
**NOFX** は、複数の AI モデルを使用して暗号通貨先物を自動取引できるオープンソースの AI 取引システムです。Web インターフェースで戦略を設定し、リアルタイムでパフォーマンスを監視し、AI エージェントを競わせて最適な取引アプローチを見つけます。
|
||||
|
||||
### コア機能
|
||||
|
||||
- **マルチ AI サポート**: DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi を実行 - いつでもモデルを切り替え可能
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **マルチ取引所**: Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter で統一取引
|
||||
- **ストラテジースタジオ**: コインソース、インジケーター、リスク管理を設定するビジュアル戦略ビルダー
|
||||
- **AI 競争モード**: 複数の AI トレーダーがリアルタイムで競争、パフォーマンスを並べて追跡
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
||||
|
||||
### 公式リンク
|
||||
|
||||
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
|
||||
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
||||
|
||||
## 開発者コミュニティ
|
||||
@@ -30,6 +44,52 @@ Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニテ
|
||||
|
||||
---
|
||||
|
||||
## 始める前に
|
||||
|
||||
NOFXを使用するには以下が必要です:
|
||||
|
||||
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
|
||||
2. **AI モデル API キー** - サポートされているプロバイダーから取得(コスト効率の良いDeepSeekを推奨)
|
||||
|
||||
---
|
||||
|
||||
## サポート取引所
|
||||
|
||||
### CEX (中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ サポート | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ サポート | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ サポート | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (分散型永久先物取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## サポート AI モデル
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ サポート | [API キー取得](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ サポート | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ サポート | [API キー取得](https://platform.openai.com) |
|
||||
| **Claude** | ✅ サポート | [API キー取得](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ サポート | [API キー取得](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ サポート | [API キー取得](https://console.x.ai) |
|
||||
| **Kimi** | ✅ サポート | [API キー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### オプション 1: Docker デプロイ(推奨)
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
# NOFX - AI 트레이딩 시스템
|
||||
<h1 align="center">NOFX — 오픈소스 AI 트레이딩 OS</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>AI 기반 금융 거래를 위한 인프라 레이어</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
**언어:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [한국어](README.md)
|
||||
|
||||
---
|
||||
|
||||
## AI 기반 암호화폐 거래 플랫폼
|
||||
|
||||
**NOFX**는 여러 AI 모델을 실행하여 암호화폐 선물을 자동으로 거래할 수 있는 오픈소스 AI 거래 시스템입니다. 웹 인터페이스를 통해 전략을 구성하고, 실시간으로 성과를 모니터링하며, AI 에이전트들이 최적의 거래 방식을 찾도록 경쟁시킵니다.
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **다중 AI 지원**: DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi 실행 - 언제든 모델 전환 가능
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **다중 거래소**: Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter에서 통합 거래
|
||||
- **전략 스튜디오**: 코인 소스, 지표, 리스크 제어를 설정하는 시각적 전략 빌더
|
||||
- **AI 경쟁 모드**: 여러 AI 트레이더가 실시간으로 경쟁, 성과를 나란히 추적
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
||||
|
||||
### 공식 링크
|
||||
|
||||
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
|
||||
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
||||
|
||||
## 개발자 커뮤니티
|
||||
@@ -30,6 +44,52 @@ Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://
|
||||
|
||||
---
|
||||
|
||||
## 시작하기 전에
|
||||
|
||||
NOFX를 사용하려면 다음이 필요합니다:
|
||||
|
||||
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
|
||||
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
|
||||
|
||||
---
|
||||
|
||||
## 지원 거래소
|
||||
|
||||
### CEX (중앙화 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ 지원 | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ 지원 | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ 지원 | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 지원 AI 모델
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ 지원 | [API 키 받기](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ 지원 | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ 지원 | [API 키 받기](https://platform.openai.com) |
|
||||
| **Claude** | ✅ 지원 | [API 키 받기](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ 지원 | [API 키 받기](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ 지원 | [API 키 받기](https://console.x.ai) |
|
||||
| **Kimi** | ✅ 지원 | [API 키 받기](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 옵션 1: Docker 배포 (권장)
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
# NOFX - AI Торговая Система
|
||||
<h1 align="center">NOFX — Open Source AI Торговая ОС</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Инфраструктурный слой для AI-powered финансовой торговли</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
**Языки:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Русский](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютная торговая платформа на базе ИИ
|
||||
|
||||
**NOFX** — это open-source AI торговая система, позволяющая запускать несколько AI моделей для автоматической торговли криптовалютными фьючерсами. Настраивайте стратегии через веб-интерфейс, отслеживайте эффективность в реальном времени и позвольте AI агентам конкурировать за лучший торговый подход.
|
||||
|
||||
### Основные функции
|
||||
|
||||
- **Мульти-AI поддержка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — переключайтесь между моделями в любое время
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Мульти-биржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter с единой платформы
|
||||
- **Студия стратегий**: Визуальный конструктор стратегий с источниками монет, индикаторами и контролем рисков
|
||||
- **Режим AI-соревнования**: Несколько AI трейдеров соревнуются в реальном времени, отслеживание эффективности бок о бок
|
||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
|
||||
|
||||
### Официальные ссылки
|
||||
|
||||
- **Официальный сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель данных**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документация API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
|
||||
|
||||
## Сообщество разработчиков
|
||||
@@ -30,6 +44,52 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед началом
|
||||
|
||||
Для использования NOFX вам понадобится:
|
||||
|
||||
1. **Аккаунт биржи** - Зарегистрируйтесь на поддерживаемой бирже и создайте API ключи с правами торговли
|
||||
2. **API ключ AI модели** - Получите от любого поддерживаемого провайдера (рекомендуется DeepSeek для экономии)
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые биржи
|
||||
|
||||
### CEX (Централизованные биржи)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Поддерживается | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Поддерживается | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Поддерживается | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Децентрализованные биржи)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Поддерживается | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Поддерживается | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Поддерживается | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые AI модели
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Поддерживается | [Получить](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Поддерживается | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Поддерживается | [Получить](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Поддерживается | [Получить](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Поддерживается | [Получить](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Поддерживается | [Получить](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Поддерживается | [Получить](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Вариант 1: Docker развёртывание (рекомендуется)
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
# NOFX - AI Торгова Система
|
||||
<h1 align="center">NOFX — Open Source AI Торгова ОС</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Інфраструктурний рівень для AI-powered фінансової торгівлі</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
**Мови:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Українська](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Криптовалютна торгова платформа на базі ШІ
|
||||
|
||||
**NOFX** — це open-source AI торгова система, що дозволяє запускати кілька AI моделей для автоматичної торгівлі криптовалютними ф'ючерсами. Налаштовуйте стратегії через веб-інтерфейс, відстежуйте ефективність у реальному часі та дозвольте AI агентам конкурувати за найкращий торговий підхід.
|
||||
|
||||
### Основні функції
|
||||
|
||||
- **Мульти-AI підтримка**: Запускайте DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi — перемикайтеся між моделями будь-коли
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Мульти-біржа**: Торгуйте на Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter з єдиної платформи
|
||||
- **Студія стратегій**: Візуальний конструктор стратегій з джерелами монет, індикаторами та контролем ризиків
|
||||
- **Режим AI-змагання**: Кілька AI трейдерів змагаються в реальному часі, відстеження ефективності пліч-о-пліч
|
||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
|
||||
|
||||
### Офіційні посилання
|
||||
|
||||
- **Офіційний сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель даних**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документація API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
|
||||
|
||||
## Спільнота розробників
|
||||
@@ -30,6 +44,52 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед початком
|
||||
|
||||
Для використання NOFX вам знадобиться:
|
||||
|
||||
1. **Акаунт біржі** - Зареєструйтесь на підтримуваній біржі та створіть API ключі з правами торгівлі
|
||||
2. **API ключ AI моделі** - Отримайте від будь-якого підтримуваного провайдера (рекомендується DeepSeek для економії)
|
||||
|
||||
---
|
||||
|
||||
## Підтримувані біржі
|
||||
|
||||
### CEX (Централізовані біржі)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Підтримується | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Підтримується | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Підтримується | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Децентралізовані біржі)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Підтримується | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Підтримується | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Підтримується | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Підтримувані AI моделі
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Підтримується | [Отримати](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Підтримується | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Підтримується | [Отримати](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Підтримується | [Отримати](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Підтримується | [Отримати](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Підтримується | [Отримати](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Підтримується | [Отримати](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Швидкий старт
|
||||
|
||||
### Варіант 1: Docker розгортання (рекомендовано)
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
# NOFX - Hệ Thống Giao Dịch AI
|
||||
<h1 align="center">NOFX — Hệ Điều Hành Giao Dịch AI Mã Nguồn Mở</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>Lớp cơ sở hạ tầng cho giao dịch tài chính AI-powered</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
**Ngôn ngữ:** [English](../../../README.md) | [中文](../zh-CN/README.md) | [Tiếng Việt](README.md)
|
||||
|
||||
---
|
||||
|
||||
## Nền Tảng Giao Dịch Crypto Sử Dụng AI
|
||||
|
||||
**NOFX** là hệ thống giao dịch AI mã nguồn mở cho phép bạn chạy nhiều mô hình AI để tự động giao dịch hợp đồng tương lai crypto. Cấu hình chiến lược qua giao diện web, theo dõi hiệu suất theo thời gian thực, và để các AI agent cạnh tranh tìm ra phương pháp giao dịch tốt nhất.
|
||||
|
||||
### Tính Năng Chính
|
||||
|
||||
- **Hỗ trợ Đa AI**: Chạy DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi - chuyển đổi mô hình bất cứ lúc nào
|
||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||
- **Đa Sàn Giao Dịch**: Giao dịch trên Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster DEX, Lighter từ một nền tảng
|
||||
- **Strategy Studio**: Trình tạo chiến lược trực quan với nguồn coin, chỉ báo và kiểm soát rủi ro
|
||||
- **Chế Độ Thi Đấu AI**: Nhiều AI trader cạnh tranh theo thời gian thực, theo dõi hiệu suất song song
|
||||
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
||||
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
|
||||
|
||||
### Liên Kết Chính Thức
|
||||
|
||||
- **Website Chính Thức**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Bảng Điều Khiển Dữ Liệu**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Tài Liệu API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
|
||||
|
||||
## Cộng Đồng Nhà Phát Triển
|
||||
@@ -30,6 +44,52 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
|
||||
|
||||
---
|
||||
|
||||
## Trước Khi Bắt Đầu
|
||||
|
||||
Để sử dụng NOFX, bạn cần:
|
||||
|
||||
1. **Tài khoản sàn giao dịch** - Đăng ký trên sàn được hỗ trợ và tạo API key với quyền giao dịch
|
||||
2. **API Key mô hình AI** - Lấy từ nhà cung cấp được hỗ trợ (khuyến nghị DeepSeek để tiết kiệm chi phí)
|
||||
|
||||
---
|
||||
|
||||
## Sàn Giao Dịch Được Hỗ Trợ
|
||||
|
||||
### CEX (Sàn Tập Trung)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Hỗ trợ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ Hỗ trợ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ Hỗ trợ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (Sàn Phi Tập Trung)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Hỗ trợ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Hỗ trợ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Hỗ trợ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Mô Hình AI Được Hỗ Trợ
|
||||
|
||||
| Mô hình AI | Trạng thái | Lấy API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Hỗ trợ | [Lấy API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Hỗ trợ | [Lấy API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Hỗ trợ | [Lấy API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Hỗ trợ | [Lấy API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Hỗ trợ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Hỗ trợ | [Lấy API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Hỗ trợ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
# NOFX - AI 交易系统
|
||||
<h1 align="center">NOFX — 开源 AI 交易操作系统</h1>
|
||||
|
||||
[](https://golang.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
<p align="center">
|
||||
<strong>AI 驱动金融交易的基础设施层</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0+-3178C6?style=flat&logo=typescript" alt="TypeScript"></a>
|
||||
</p>
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
|
||||
@@ -16,14 +28,10 @@
|
||||
|
||||
---
|
||||
|
||||
## AI 驱动的加密货币交易平台
|
||||
|
||||
**NOFX** 是一个开源的 AI 交易系统,让你可以运行多个 AI 模型自动交易加密货币期货。通过 Web 界面配置策略,实时监控表现,让多个 AI 代理竞争找出最佳交易方案。
|
||||
|
||||
### 核心功能
|
||||
|
||||
- **多 AI 支持**: 运行 DeepSeek、通义千问、GPT、Claude、Gemini、Grok、Kimi - 随时切换模型
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **多交易所**: 在 Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster DEX、Lighter 统一交易
|
||||
- **策略工作室**: 可视化策略构建器,配置币种来源、指标和风控参数
|
||||
- **AI 竞赛模式**: 多个 AI 交易员实时竞争,并排追踪表现
|
||||
- **Web 配置**: 无需编辑 JSON - 通过 Web 界面完成所有配置
|
||||
@@ -34,6 +42,12 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### 官方链接
|
||||
|
||||
- **官网**: [https://nofxai.com](https://nofxai.com)
|
||||
- **数据站点**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 文档**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
||||
|
||||
## 开发者社区
|
||||
@@ -42,19 +56,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
## 开始之前
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

|
||||
*多 AI 排行榜,实时性能对比*
|
||||
使用 NOFX 你需要准备:
|
||||
|
||||
### 仪表板 - 市场图表视图
|
||||

|
||||
*专业交易仪表板,TradingView 风格图表*
|
||||
|
||||
### 策略工作室
|
||||

|
||||
*多数据源策略配置与 AI 测试*
|
||||
1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
|
||||
2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek,性价比最高)
|
||||
|
||||
---
|
||||
|
||||
@@ -67,6 +74,9 @@
|
||||
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| **KuCoin** | ✅ 已支持 | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| **Gate** | ✅ 已支持 | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Perp-DEX (去中心化永续交易所)
|
||||
|
||||
@@ -92,9 +102,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

|
||||
*多 AI 排行榜,实时性能对比*
|
||||
|
||||
### 仪表板 - 市场图表视图
|
||||

|
||||
*专业交易仪表板,TradingView 风格图表*
|
||||
|
||||
### 策略工作室
|
||||

|
||||
*多数据源策略配置与 AI 测试*
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 (推荐)
|
||||
### 一键安装 (本地/服务器)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -103,6 +129,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
完成!打开浏览器访问 **http://127.0.0.1:3000**
|
||||
|
||||
### 一键云部署 (Railway)
|
||||
|
||||
一键部署到 Railway - 无需自己搭建服务器:
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
部署后,Railway 会提供一个公网 URL 访问你的 NOFX 实例。
|
||||
|
||||
### Docker Compose (手动)
|
||||
|
||||
```bash
|
||||
|
||||
281
docs/market-regime-classification-en.md
Normal file
281
docs/market-regime-classification-en.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Market Regime Classification Framework
|
||||
|
||||
> A comprehensive market state identification system for quantitative trading strategy matching
|
||||
|
||||
---
|
||||
|
||||
## 1. Classification Dimensions Overview
|
||||
|
||||
Market state identification requires analysis across multiple dimensions:
|
||||
|
||||
| Dimension | Sub-dimensions | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| **Trend** | Direction, Strength | Determine market movement direction and momentum |
|
||||
| **Volatility** | Amplitude, Frequency | Measure price fluctuation characteristics |
|
||||
| **Structure** | Pattern, Phase | Identify market structure and cycle position |
|
||||
|
||||
---
|
||||
|
||||
## 2. Primary Classification (5 Categories)
|
||||
|
||||
### 2.1 Classification Overview
|
||||
|
||||
| Code | Name | Key Characteristics | Suitable Strategies |
|
||||
|------|------|---------------------|---------------------|
|
||||
| `TREND_UP` | Uptrend | Higher highs & higher lows | Trend following, Breakout |
|
||||
| `TREND_DOWN` | Downtrend | Lower highs & lower lows | Trend following, Short selling |
|
||||
| `RANGE` | Range-bound | Price oscillates within bounds | Grid trading, Mean reversion |
|
||||
| `TRANSITION` | Transition | Uncertain directional period | Wait & watch, Small positions |
|
||||
| `BREAKOUT` | Breakout | Price breaks key levels | Breakout trading |
|
||||
|
||||
### 2.2 Identification Indicators
|
||||
|
||||
- **ADX (Average Directional Index)**: Measures trend strength
|
||||
- ADX > 25: Clear trend exists
|
||||
- ADX < 20: Range-bound market
|
||||
- **EMA Alignment**: Determines trend direction
|
||||
- EMA20 > EMA50 > EMA200: Bullish alignment
|
||||
- EMA20 < EMA50 < EMA200: Bearish alignment
|
||||
|
||||
---
|
||||
|
||||
## 3. Secondary Classification (18 Sub-categories)
|
||||
|
||||
### 3.1 Uptrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TU_STRONG_LOW_VOL` | Strong Uptrend · Low Vol | Steady rise, shallow pullbacks | ADX>40, ATR%<2%, Pullback<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | Strong Uptrend · High Vol | Rapid surge, high volatility | ADX>40, ATR%>4%, MACD histogram expanding |
|
||||
| `TU_WEAK_CHOPPY` | Weak Uptrend · Choppy | Two steps forward, one back | ADX 20-30, RSI oscillating 50-70 |
|
||||
| `TU_PARABOLIC` | Parabolic Acceleration | Exponential price increase | Price far from MA, RSI>80, Volume surge |
|
||||
| `TU_EXHAUSTION` | Uptrend Exhaustion | New highs but weakening momentum | Price new high + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Heavy trend following, pyramid adding
|
||||
- Strong High Vol: Medium position, trailing stops
|
||||
- Weak Choppy: Light swing trading
|
||||
- Parabolic: Cautious, prepare to exit
|
||||
- Exhaustion: Reduce positions, prepare for reversal
|
||||
|
||||
### 3.2 Downtrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TD_STRONG_LOW_VOL` | Strong Downtrend · Low Vol | Steady decline, weak bounces | ADX>40, ATR%<2%, Bounce<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | Strong Downtrend · High Vol | Panic selling, wild swings | ADX>40, ATR%>5%, VIX spike |
|
||||
| `TD_WEAK_CHOPPY` | Weak Downtrend · Choppy | Grinding lower with bounces | ADX 20-30, RSI oscillating 30-50 |
|
||||
| `TD_CAPITULATION` | Capitulation | High volume crash, extreme fear | RSI<20, Volume>3x average |
|
||||
| `TD_EXHAUSTION` | Downtrend Exhaustion | New lows but selling pressure fading | Price new low + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Short trend following
|
||||
- Strong High Vol: Stay flat or light hedge
|
||||
- Weak Choppy: Wait for stabilization
|
||||
- Capitulation: Light bottom fishing possible
|
||||
- Exhaustion: Gradually build long positions
|
||||
|
||||
### 3.3 Range Sub-categories (4 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `RG_TIGHT_LOW_VOL` | Tight Range · Low Vol | Extreme contraction, coiling | BB Width<2%, ATR at new lows |
|
||||
| `RG_TIGHT_HIGH_VOL` | Tight Range · High Vol | Violent swings within range | BB Width<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | Wide Range · Low Vol | Large range, slow movement | BB Width>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | Wide Range · High Vol | Large range, fast movement | BB Width>5%, ATR%>3% |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Tight Low Vol: Dense grid, wait for breakout
|
||||
- Tight High Vol: Fast grid, small frequent profits
|
||||
- Wide Low Vol: Sparse grid, patient holding
|
||||
- Wide High Vol: Swing trading, high profit targets
|
||||
|
||||
### 3.4 Transition (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TR_BOTTOM_FORMING` | Bottom Forming | Decline slowing, testing support | Price stabilizing + Volume drying up + RSI divergence |
|
||||
| `TR_TOP_FORMING` | Top Forming | Rally slowing, testing resistance | Price stalling + Volume drying up + RSI divergence |
|
||||
|
||||
### 3.5 Breakout (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `BK_UPWARD` | Upward Breakout | Breaking resistance with volume | Price>Previous high, Volume>2x, BB breakout |
|
||||
| `BK_DOWNWARD` | Downward Breakout | Breaking support with volume | Price<Previous low, Volume>2x, BB breakdown |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tertiary Classification (36 Ultra-fine Categories)
|
||||
|
||||
### 4.1 Trend Phase Classification
|
||||
|
||||
Uptrend lifecycle consists of 5 phases:
|
||||
|
||||
| Phase Code | Name | Description | Quantitative Criteria |
|
||||
|------------|------|-------------|----------------------|
|
||||
| `TU_S1_INITIATION` | Uptrend Initiation | First break above MA or previous high | MACD bullish cross, Price>EMA20 |
|
||||
| `TU_S2_ACCELERATION` | Uptrend Acceleration | Momentum increasing, slope steepening | MACD histogram expanding, ADX rising |
|
||||
| `TU_S3_MAIN_WAVE` | Main Wave | Sustained rise, shallow pullbacks | RSI 60-80, Pullbacks hold EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | Uptrend Exhaustion | Slowing momentum, divergences appearing | RSI divergence, MACD divergence |
|
||||
| `TU_S5_REVERSAL` | Trend Reversal | Breakdown, trend ending | Break below EMA50, MACD bearish cross |
|
||||
|
||||
Downtrend phases follow same pattern: `TD_S1` through `TD_S5`
|
||||
|
||||
### 4.2 Range Position Classification
|
||||
|
||||
| Position Code | Name | Description | Strategy Suggestion |
|
||||
|---------------|------|-------------|---------------------|
|
||||
| `RG_UPPER` | Upper Range | Price near resistance | Bias toward short |
|
||||
| `RG_MIDDLE` | Mid Range | Price near middle band | Neutral grid trading |
|
||||
| `RG_LOWER` | Lower Range | Price near support | Bias toward long |
|
||||
| `RG_SQUEEZE` | Squeeze Pattern | Highs and lows converging | Wait for direction |
|
||||
| `RG_EXPAND` | Expanding Pattern | Highs and lows diverging | Boundary reversal |
|
||||
|
||||
### 4.3 Volatility Grades
|
||||
|
||||
| Code | Name | ATR% | BB Width | Strategy Suggestion |
|
||||
|------|------|------|----------|---------------------|
|
||||
| `VOL_EXTREME_LOW` | Extreme Low Vol | <1% | <1.5% | Option selling |
|
||||
| `VOL_LOW` | Low Volatility | 1-2% | 1.5-2.5% | Grid / Mean reversion |
|
||||
| `VOL_NORMAL` | Normal Volatility | 2-3% | 2.5-4% | Trend following |
|
||||
| `VOL_HIGH` | High Volatility | 3-5% | 4-6% | Momentum / Breakout |
|
||||
| `VOL_EXTREME_HIGH` | Extreme High Vol | >5% | >6% | Reduce exposure / Hedge |
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete State Encoding Rules
|
||||
|
||||
### 5.1 Encoding Format
|
||||
|
||||
```
|
||||
{Primary}_{Volatility}_{Phase}_{Position}
|
||||
```
|
||||
|
||||
### 5.2 Encoding Examples
|
||||
|
||||
| Full Code | Interpretation |
|
||||
|-----------|----------------|
|
||||
| `TU_LV_S3_M` | Uptrend_LowVol_MainWave_Middle |
|
||||
| `TD_HV_S2_L` | Downtrend_HighVol_Acceleration_Lower |
|
||||
| `RG_NV_SQ_U` | Range_NormalVol_Squeeze_Upper |
|
||||
| `BK_HV_UP_M` | Breakout_HighVol_Upward_Middle |
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Identification Indicators
|
||||
|
||||
### 6.1 Trend Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| ADX | 14-period Average Directional Index | >40 Strong, 25-40 Medium, <25 Weak/Range |
|
||||
| Trend Score | Composite EMA/MACD/Price structure | -100 to +100, Positive=Bullish, Negative=Bearish |
|
||||
| EMA Alignment | Relative position of EMA20/50/200 | Bullish/Bearish/Mixed alignment |
|
||||
|
||||
### 6.2 Volatility Indicators
|
||||
|
||||
| Indicator | Calculation | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| ATR Percent | ATR(14) / Current Price × 100% | Measure relative volatility |
|
||||
| BB Width | (Upper - Lower) / Middle × 100% | Measure price range |
|
||||
| Volatility Rank | Current vol percentile in history | Determine vol level |
|
||||
|
||||
### 6.3 Momentum Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| RSI | 14-period Relative Strength Index | >70 Overbought, <30 Oversold, 50 Neutral |
|
||||
| MACD Histogram | MACD - Signal | Positive=Bullish momentum, Negative=Bearish |
|
||||
| Momentum Score | Composite RSI/MACD/Volume | Measure current momentum |
|
||||
|
||||
### 6.4 Structure Indicators
|
||||
|
||||
| Indicator | Description | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Swing Structure | HH/HL/LH/LL sequence | Determine trend structure |
|
||||
| Support/Resistance | Key price levels | Define trading range |
|
||||
| Volume Profile | Volume-price relationship | Validate price action |
|
||||
|
||||
---
|
||||
|
||||
## 7. Strategy Matching Matrix
|
||||
|
||||
### 7.1 Regime-Strategy Mapping
|
||||
|
||||
| Regime Type | Recommended Strategy | Position Size | Stop Loss |
|
||||
|-------------|---------------------|---------------|-----------|
|
||||
| Strong Uptrend · Low Vol | Trend following + Pyramid | 60-80% | ATR×2 |
|
||||
| Strong Uptrend · High Vol | Momentum + Quick profit | 40-60% | ATR×1.5 |
|
||||
| Uptrend Exhaustion | Reduce + Reversal short | 20-30% | Previous high |
|
||||
| Panic Decline | Wait or light bottom fish | 10-20% | Wide stop |
|
||||
| Low Vol Range | Grid trading | 50-70% | Range boundary |
|
||||
| High Vol Range | Swing trading | 30-50% | ATR×2 |
|
||||
| Squeeze Pattern | Wait for breakout | 10-20% | - |
|
||||
| Upward Breakout | Chase + Add on pullback | 50-70% | Breakout level |
|
||||
| Bottom Formation | Scale in gradually | 20-40% | New low |
|
||||
|
||||
### 7.2 Grid Strategy Parameter Matching
|
||||
|
||||
| Range Type | Grid Levels | Grid Spacing | Other Parameters |
|
||||
|------------|-------------|--------------|------------------|
|
||||
| Tight Low Vol | 30-50 levels | Small spacing | Enable Maker Only |
|
||||
| Tight High Vol | 15-25 levels | Small spacing | Fast execution mode |
|
||||
| Wide Low Vol | 10-20 levels | Large spacing | Patient execution |
|
||||
| Wide High Vol | 15-25 levels | Large spacing | High profit targets |
|
||||
| Squeeze Pattern | Pause grid | - | Wait for breakout signal |
|
||||
| Upper Range | Short bias | Medium | Increase sell weight |
|
||||
| Lower Range | Long bias | Medium | Increase buy weight |
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-time Monitoring Guidelines
|
||||
|
||||
### 8.1 State Transition Triggers
|
||||
|
||||
| Current State | Trigger Condition | Transitions To |
|
||||
|---------------|-------------------|----------------|
|
||||
| Range | Price breakout + Volume + ADX rising | Breakout |
|
||||
| Uptrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Downtrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Breakout | Failed breakout, price returns | Range |
|
||||
| Exhaustion | Confirmed reversal breakout | Opposite trend |
|
||||
|
||||
### 8.2 Risk Control Rules
|
||||
|
||||
| Regime State | Max Position | Risk Per Trade | Special Rules |
|
||||
|--------------|--------------|----------------|---------------|
|
||||
| Strong Trend | 80% | 2% | Adding allowed |
|
||||
| Weak Trend | 50% | 1.5% | No adding |
|
||||
| Range | 60% | 1% | Diversified holding |
|
||||
| Transition | 30% | 1% | Reduce activity |
|
||||
| High Volatility | 40% | 0.5% | Wide stops |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix
|
||||
|
||||
### 9.1 Abbreviation Reference
|
||||
|
||||
| Abbrev | Full Form | Description |
|
||||
|--------|-----------|-------------|
|
||||
| TU | Trend Up | Upward trend |
|
||||
| TD | Trend Down | Downward trend |
|
||||
| RG | Range | Range-bound market |
|
||||
| TR | Transition | Trend transition |
|
||||
| BK | Breakout | Breakout pattern |
|
||||
| LV | Low Volatility | Low volatility regime |
|
||||
| HV | High Volatility | High volatility regime |
|
||||
| NV | Normal Volatility | Normal volatility regime |
|
||||
| XLV | Extreme Low Vol | Extremely low volatility |
|
||||
| XHV | Extreme High Vol | Extremely high volatility |
|
||||
|
||||
### 9.2 Document Information
|
||||
|
||||
- Version: v1.0
|
||||
- Created: January 2026
|
||||
- Applicable: Cryptocurrency, Forex, Stocks, and other financial markets
|
||||
|
||||
---
|
||||
|
||||
*This document is designed for market state identification and strategy matching in quantitative trading systems*
|
||||
281
docs/market-regime-classification-zh.md
Normal file
281
docs/market-regime-classification-zh.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 市场行情精细分类体系
|
||||
|
||||
> 用于量化交易策略匹配的市场状态识别框架
|
||||
|
||||
---
|
||||
|
||||
## 一、分类维度概览
|
||||
|
||||
市场状态识别需要从多个维度进行分析:
|
||||
|
||||
| 维度 | 子维度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **趋势维度** | 方向、强度 | 判断市场运动方向和力度 |
|
||||
| **波动维度** | 幅度、频率 | 衡量价格波动特征 |
|
||||
| **结构维度** | 形态、阶段 | 识别市场结构和所处周期 |
|
||||
|
||||
---
|
||||
|
||||
## 二、一级分类(5大类)
|
||||
|
||||
### 2.1 分类总览
|
||||
|
||||
| 代码 | 名称 | 核心特征 | 适合策略 |
|
||||
|------|------|----------|----------|
|
||||
| `TREND_UP` | 上涨趋势 | 高点/低点持续抬升 | 趋势跟踪、突破追涨 |
|
||||
| `TREND_DOWN` | 下跌趋势 | 高点/低点持续降低 | 趋势跟踪、做空策略 |
|
||||
| `RANGE` | 震荡区间 | 价格在区间内波动 | 网格交易、均值回归 |
|
||||
| `TRANSITION` | 趋势转换 | 方向不明确的过渡期 | 观望、小仓位试探 |
|
||||
| `BREAKOUT` | 突破行情 | 价格突破关键位置 | 突破追踪策略 |
|
||||
|
||||
### 2.2 识别指标
|
||||
|
||||
- **ADX(平均方向指数)**:衡量趋势强度
|
||||
- ADX > 25:存在明确趋势
|
||||
- ADX < 20:震荡市场
|
||||
- **EMA排列**:判断趋势方向
|
||||
- EMA20 > EMA50 > EMA200:多头排列
|
||||
- EMA20 < EMA50 < EMA200:空头排列
|
||||
|
||||
---
|
||||
|
||||
## 三、二级分类(18细分类)
|
||||
|
||||
### 3.1 上涨趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TU_STRONG_LOW_VOL` | 强势上涨·低波动 | 稳步上涨,回调幅度小 | ADX>40, ATR%<2%, 回调<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | 强势上涨·高波动 | 快速拉升,波动剧烈 | ADX>40, ATR%>4%, MACD柱放大 |
|
||||
| `TU_WEAK_CHOPPY` | 弱势上涨·震荡 | 涨三退二,反复磨蹭 | ADX 20-30, RSI在50-70震荡 |
|
||||
| `TU_PARABOLIC` | 抛物线加速 | 指数级加速上涨 | 价格远离均线, RSI>80, 成交量放大 |
|
||||
| `TU_EXHAUSTION` | 上涨衰竭 | 创新高但动能减弱 | 价格新高 + MACD/RSI顶背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:重仓趋势跟踪,金字塔加仓
|
||||
- 强势高波动:中等仓位,设置移动止盈
|
||||
- 弱势震荡:轻仓波段,高抛低吸
|
||||
- 抛物线加速:谨慎追涨,准备离场
|
||||
- 上涨衰竭:减仓观望,准备反转做空
|
||||
|
||||
### 3.2 下跌趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TD_STRONG_LOW_VOL` | 强势下跌·低波动 | 稳步下跌,反弹无力 | ADX>40, ATR%<2%, 反弹<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | 强势下跌·高波动 | 恐慌抛售,波动剧烈 | ADX>40, ATR%>5%, 恐慌指数飙升 |
|
||||
| `TD_WEAK_CHOPPY` | 弱势下跌·震荡 | 跌跌涨涨,磨底过程 | ADX 20-30, RSI在30-50震荡 |
|
||||
| `TD_CAPITULATION` | 恐慌投降 | 放量暴跌,情绪极端 | RSI<20, 成交量>3倍均量 |
|
||||
| `TD_EXHAUSTION` | 下跌衰竭 | 创新低但卖压减弱 | 价格新低 + MACD/RSI底背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:空头趋势跟踪
|
||||
- 强势高波动:观望或轻仓对冲
|
||||
- 弱势震荡:等待企稳信号
|
||||
- 恐慌投降:极端情况可轻仓抄底
|
||||
- 下跌衰竭:逐步建立多头仓位
|
||||
|
||||
### 3.3 震荡区间细分(4种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `RG_TIGHT_LOW_VOL` | 窄幅震荡·低波动 | 极度收敛,蓄势待发 | 布林带宽度<2%, ATR创新低 |
|
||||
| `RG_TIGHT_HIGH_VOL` | 窄幅震荡·高波动 | 区间内剧烈波动 | 布林带宽度<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | 宽幅震荡·低波动 | 大区间慢速波动 | 布林带宽度>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | 宽幅震荡·高波动 | 大区间快速波动 | 布林带宽度>5%, ATR%>3% |
|
||||
|
||||
**策略匹配:**
|
||||
- 窄幅低波动:密集网格,等待突破
|
||||
- 窄幅高波动:快速网格,小利润多次
|
||||
- 宽幅低波动:稀疏网格,耐心持有
|
||||
- 宽幅高波动:波段交易,高利润目标
|
||||
|
||||
### 3.4 转换过渡(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TR_BOTTOM_FORMING` | 底部形成中 | 下跌放缓,试探支撑 | 价格止跌 + 成交量萎缩 + RSI底背离 |
|
||||
| `TR_TOP_FORMING` | 顶部形成中 | 上涨放缓,试探压力 | 价格滞涨 + 成交量萎缩 + RSI顶背离 |
|
||||
|
||||
### 3.5 突破行情(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `BK_UPWARD` | 向上突破 | 突破阻力位并放量 | 价格>前高, 成交量>2倍, 布林带突破 |
|
||||
| `BK_DOWNWARD` | 向下突破 | 跌破支撑位并放量 | 价格<前低, 成交量>2倍, 布林带跌破 |
|
||||
|
||||
---
|
||||
|
||||
## 四、三级分类(36超细分类)
|
||||
|
||||
### 4.1 趋势阶段细分
|
||||
|
||||
上涨趋势生命周期分为5个阶段:
|
||||
|
||||
| 阶段代码 | 名称 | 特征描述 | 量化判断标准 |
|
||||
|----------|------|----------|--------------|
|
||||
| `TU_S1_INITIATION` | 上涨启动期 | 首次突破均线或前高 | MACD金叉, 价格突破EMA20 |
|
||||
| `TU_S2_ACCELERATION` | 上涨加速期 | 动能增强,斜率加大 | MACD柱持续增大, ADX上升 |
|
||||
| `TU_S3_MAIN_WAVE` | 主升浪阶段 | 持续上涨,回调幅度浅 | RSI维持60-80, 回调不破EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | 上涨衰竭期 | 涨速放缓,出现背离 | RSI顶背离, MACD顶背离 |
|
||||
| `TU_S5_REVERSAL` | 趋势反转期 | 破位下跌,趋势结束 | 跌破EMA50, MACD死叉 |
|
||||
|
||||
下跌趋势同理,代码为 `TD_S1` 至 `TD_S5`
|
||||
|
||||
### 4.2 震荡位置细分
|
||||
|
||||
| 位置代码 | 名称 | 特征描述 | 策略建议 |
|
||||
|----------|------|----------|----------|
|
||||
| `RG_UPPER` | 区间上沿震荡 | 价格接近阻力位 | 偏空操作为主 |
|
||||
| `RG_MIDDLE` | 区间中部震荡 | 价格在中轨附近 | 双向网格交易 |
|
||||
| `RG_LOWER` | 区间下沿震荡 | 价格接近支撑位 | 偏多操作为主 |
|
||||
| `RG_SQUEEZE` | 收敛三角震荡 | 高低点逐渐收窄 | 等待方向选择 |
|
||||
| `RG_EXPAND` | 扩散三角震荡 | 高低点逐渐扩张 | 边界反转操作 |
|
||||
|
||||
### 4.3 波动率等级
|
||||
|
||||
| 代码 | 名称 | ATR百分比 | 布林带宽度 | 策略建议 |
|
||||
|------|------|-----------|------------|----------|
|
||||
| `VOL_EXTREME_LOW` | 极低波动 | <1% | <1.5% | 期权卖方策略 |
|
||||
| `VOL_LOW` | 低波动 | 1-2% | 1.5-2.5% | 网格/均值回归 |
|
||||
| `VOL_NORMAL` | 正常波动 | 2-3% | 2.5-4% | 趋势跟踪 |
|
||||
| `VOL_HIGH` | 高波动 | 3-5% | 4-6% | 动量/突破 |
|
||||
| `VOL_EXTREME_HIGH` | 极高波动 | >5% | >6% | 减仓/对冲 |
|
||||
|
||||
---
|
||||
|
||||
## 五、完整状态编码规则
|
||||
|
||||
### 5.1 编码格式
|
||||
|
||||
```
|
||||
{一级分类}_{波动等级}_{阶段}_{位置}
|
||||
```
|
||||
|
||||
### 5.2 编码示例
|
||||
|
||||
| 完整代码 | 含义解释 |
|
||||
|----------|----------|
|
||||
| `TU_LV_S3_M` | 上涨趋势_低波动_主升浪_中部位置 |
|
||||
| `TD_HV_S2_L` | 下跌趋势_高波动_加速期_下部位置 |
|
||||
| `RG_NV_SQ_U` | 震荡区间_正常波动_收敛形态_上沿位置 |
|
||||
| `BK_HV_UP_M` | 突破行情_高波动_向上突破_中部位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、核心识别指标
|
||||
|
||||
### 6.1 趋势指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| ADX | 14周期平均方向指数 | >40强趋势, 25-40中等, <25弱/震荡 |
|
||||
| 趋势评分 | 综合EMA/MACD/价格结构 | -100到+100, 正数多头,负数空头 |
|
||||
| EMA排列 | EMA20/50/200相对位置 | 多头排列/空头排列/混乱 |
|
||||
|
||||
### 6.2 波动指标
|
||||
|
||||
| 指标 | 计算方法 | 用途 |
|
||||
|------|----------|------|
|
||||
| ATR百分比 | ATR(14) / 当前价格 × 100% | 衡量相对波动幅度 |
|
||||
| 布林带宽度 | (上轨-下轨) / 中轨 × 100% | 衡量价格波动区间 |
|
||||
| 波动率排名 | 当前波动在历史中的分位 | 判断波动率高低 |
|
||||
|
||||
### 6.3 动量指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| RSI | 14周期相对强弱指数 | >70超买, <30超卖, 50中性 |
|
||||
| MACD柱 | MACD - Signal | 正数多头动能,负数空头动能 |
|
||||
| 动量评分 | 综合RSI/MACD/成交量 | 衡量当前动能强弱 |
|
||||
|
||||
### 6.4 结构指标
|
||||
|
||||
| 指标 | 说明 | 用途 |
|
||||
|------|------|------|
|
||||
| 高低点结构 | HH/HL/LH/LL序列 | 判断趋势结构 |
|
||||
| 支撑阻力位 | 关键价格水平 | 确定交易区间 |
|
||||
| 成交量形态 | 量价配合关系 | 验证价格走势 |
|
||||
|
||||
---
|
||||
|
||||
## 七、策略匹配矩阵
|
||||
|
||||
### 7.1 行情类型与策略对应
|
||||
|
||||
| 行情类型 | 推荐策略 | 建议仓位 | 止损设置 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强势上涨·低波动 | 趋势跟踪+金字塔加仓 | 60-80% | ATR×2 |
|
||||
| 强势上涨·高波动 | 动量突破+快速止盈 | 40-60% | ATR×1.5 |
|
||||
| 上涨衰竭期 | 减仓+反转信号做空 | 20-30% | 前高 |
|
||||
| 恐慌下跌 | 观望或轻仓抄底 | 10-20% | 宽止损 |
|
||||
| 低波动震荡 | 网格交易 | 50-70% | 区间边界 |
|
||||
| 高波动震荡 | 波段高抛低吸 | 30-50% | ATR×2 |
|
||||
| 收敛等待 | 蓄势等突破 | 10-20% | - |
|
||||
| 向上突破 | 追涨+回踩加仓 | 50-70% | 突破位 |
|
||||
| 底部形成 | 分批建仓 | 20-40% | 新低 |
|
||||
|
||||
### 7.2 网格策略参数匹配
|
||||
|
||||
| 震荡类型 | 网格层数 | 网格间距 | 其他参数 |
|
||||
|----------|----------|----------|----------|
|
||||
| 窄幅低波动 | 30-50层 | 小间距 | 启用Maker Only |
|
||||
| 窄幅高波动 | 15-25层 | 小间距 | 快速成交模式 |
|
||||
| 宽幅低波动 | 10-20层 | 大间距 | 耐心等待成交 |
|
||||
| 宽幅高波动 | 15-25层 | 大间距 | 高利润目标 |
|
||||
| 收敛形态 | 暂停网格 | - | 等待突破信号 |
|
||||
| 区间上沿 | 偏空配置 | 中等 | 卖单权重增加 |
|
||||
| 区间下沿 | 偏多配置 | 中等 | 买单权重增加 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实时监控建议
|
||||
|
||||
### 8.1 状态转换触发条件
|
||||
|
||||
| 当前状态 | 触发条件 | 转换到 |
|
||||
|----------|----------|--------|
|
||||
| 震荡区间 | 价格突破+放量+ADX上升 | 突破行情 |
|
||||
| 上涨趋势 | RSI顶背离+成交量萎缩 | 上涨衰竭 |
|
||||
| 下跌趋势 | RSI底背离+成交量萎缩 | 下跌衰竭 |
|
||||
| 突破行情 | 突破失败回落 | 震荡区间 |
|
||||
| 趋势衰竭 | 反向突破确认 | 反向趋势 |
|
||||
|
||||
### 8.2 风险控制规则
|
||||
|
||||
| 行情状态 | 最大仓位 | 单笔风险 | 特殊规则 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强趋势 | 80% | 2% | 可加仓 |
|
||||
| 弱趋势 | 50% | 1.5% | 不加仓 |
|
||||
| 震荡 | 60% | 1% | 分散持仓 |
|
||||
| 转换期 | 30% | 1% | 减少操作 |
|
||||
| 高波动 | 40% | 0.5% | 宽止损 |
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 缩写对照表
|
||||
|
||||
| 缩写 | 英文全称 | 中文含义 |
|
||||
|------|----------|----------|
|
||||
| TU | Trend Up | 上涨趋势 |
|
||||
| TD | Trend Down | 下跌趋势 |
|
||||
| RG | Range | 震荡区间 |
|
||||
| TR | Transition | 趋势转换 |
|
||||
| BK | Breakout | 突破行情 |
|
||||
| LV | Low Volatility | 低波动 |
|
||||
| HV | High Volatility | 高波动 |
|
||||
| NV | Normal Volatility | 正常波动 |
|
||||
| XLV | Extreme Low Vol | 极低波动 |
|
||||
| XHV | Extreme High Vol | 极高波动 |
|
||||
|
||||
### 9.2 版本信息
|
||||
|
||||
- 文档版本:v1.0
|
||||
- 创建日期:2026年1月
|
||||
- 适用范围:加密货币、外汇、股票等金融市场
|
||||
|
||||
---
|
||||
|
||||
*本文档用于量化交易系统的市场状态识别和策略匹配*
|
||||
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 网格策略市场状态识别与风控设计
|
||||
|
||||
## 概述
|
||||
|
||||
增强网格策略的市场状态识别能力,实现震荡/趋势的精准判断,并根据不同震荡级别自动调整网格参数和风控策略。
|
||||
|
||||
---
|
||||
|
||||
## 一、市场状态识别
|
||||
|
||||
### 1.1 识别维度(3个)
|
||||
|
||||
| 维度 | 指标 | 作用 |
|
||||
|------|------|------|
|
||||
| 价格波动 | ATR14 + Bollinger带宽 | 判断震荡幅度 |
|
||||
| 趋势强度 | EMA20/50距离 + MACD | 判断是否有趋势 |
|
||||
| 动量 | RSI14 + 1h/4h涨跌幅 | 判断超买超卖 |
|
||||
|
||||
### 1.2 箱体指标(新增)
|
||||
|
||||
基于1小时K线的多周期Donchian通道:
|
||||
|
||||
| 箱体级别 | 周期 | 覆盖时间 | 用途 |
|
||||
|----------|------|----------|------|
|
||||
| 短期箱体 | 72根1小时 | 3天 | 日内波动边界 |
|
||||
| 中期箱体 | 240根1小时 | 10天 | 周级别震荡区间 |
|
||||
| 长期箱体 | 500根1小时 | ~21天 | 大级别趋势边界 |
|
||||
|
||||
### 1.3 判断方式
|
||||
|
||||
由AI综合分析以上指标 + 原始K线序列 + 箱体位置,输出市场状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 二、震荡分级与网格策略
|
||||
|
||||
### 2.1 四级震荡分类
|
||||
|
||||
| 级别 | 特征 | 判断依据 |
|
||||
|------|------|----------|
|
||||
| 窄幅震荡 | 价格在短期箱体内小幅波动 | Bollinger带宽 < 2%,ATR低 |
|
||||
| 标准震荡 | 价格在中期箱体内正常波动 | Bollinger带宽 2-3%,ATR正常 |
|
||||
| 宽幅震荡 | 价格接近中期箱体边缘 | Bollinger带宽 3-4%,ATR较高 |
|
||||
| 剧烈震荡 | 价格接近长期箱体边缘 | Bollinger带宽 > 4%,ATR高 |
|
||||
|
||||
### 2.2 各级别对应的网格策略
|
||||
|
||||
| 级别 | 网格密度 | 网格范围 | 单格仓位 | 总仓位上限 | 有效杠杆上限 |
|
||||
|------|----------|----------|----------|------------|--------------|
|
||||
| 窄幅震荡 | 密集 | 窄 | 小 | 30-40% | 2x |
|
||||
| 标准震荡 | 正常 | 中等 | 正常 | 60-70% | 3-4x |
|
||||
| 宽幅震荡 | 稀疏 | 宽 | 正常 | 50-60% | 3x |
|
||||
| 剧烈震荡 | 最稀疏 | 最宽 | 小 | 30-40% | 2x |
|
||||
|
||||
**核心原则:**
|
||||
- 窄幅震荡:单格仓位小 + 总仓位上限低(防击穿风险)
|
||||
- 剧烈震荡:同样保守(随时可能变趋势)
|
||||
- 标准震荡:才是放量的最佳时机
|
||||
|
||||
---
|
||||
|
||||
## 三、突破处理与恢复机制
|
||||
|
||||
### 3.1 突破判断与处理
|
||||
|
||||
**确认方式:** 收盘价突破箱体后,持续3根1小时K线不回箱体
|
||||
|
||||
| 箱体级别 | 突破处理 |
|
||||
|----------|----------|
|
||||
| 短期箱体突破 | 降低仓位到 50% |
|
||||
| 中期箱体突破 | 暂停网格 + 取消挂单 |
|
||||
| 长期箱体突破 | 暂停网格 + 取消挂单 + 平掉所有持仓 |
|
||||
|
||||
### 3.2 假突破恢复
|
||||
|
||||
**价格回到箱体内 → 以50%仓位恢复网格**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端风控面板
|
||||
|
||||
### 4.1 需要展示的信息
|
||||
|
||||
| 类别 | 显示内容 |
|
||||
|------|----------|
|
||||
| 杠杆信息 | 当前杠杆、有效杠杆、系统推荐杠杆 |
|
||||
| 仓位信息 | 当前仓位、最大仓位、仓位占比 |
|
||||
| 爆仓信息 | 爆仓价格、爆仓距离(%) |
|
||||
| 市场状态 | 当前震荡级别(窄幅/标准/宽幅/剧烈) |
|
||||
| 箱体状态 | 短期/中期/长期箱体上下沿、当前价格位置 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实现要点
|
||||
|
||||
### 5.1 后端新增
|
||||
|
||||
1. **箱体指标计算** (`market/data.go`)
|
||||
- 新增 `calculateDonchian(klines, period)` 函数
|
||||
- 返回 upper(最高价), lower(最低价)
|
||||
- 支持72/240/500三个周期
|
||||
|
||||
2. **市场状态评估** (`kernel/grid_engine.go`)
|
||||
- 更新AI prompt,加入箱体指标和K线序列
|
||||
- AI输出震荡级别判断
|
||||
|
||||
3. **网格参数动态调整** (`trader/auto_trader_grid.go`)
|
||||
- 根据震荡级别自动调整:网格密度、范围、仓位、杠杆
|
||||
- 实现有效杠杆上限控制
|
||||
|
||||
4. **突破处理逻辑** (`trader/auto_trader_grid.go`)
|
||||
- 实现三级箱体突破检测
|
||||
- 实现3根K线确认逻辑
|
||||
- 实现降级恢复机制
|
||||
|
||||
### 5.2 前端新增
|
||||
|
||||
1. **风控面板组件**
|
||||
- 杠杆信息展示
|
||||
- 仓位信息展示
|
||||
- 爆仓信息展示
|
||||
- 市场状态展示
|
||||
- 箱体状态可视化
|
||||
|
||||
### 5.3 数据模型更新
|
||||
|
||||
1. **GridConfigModel** 新增字段:
|
||||
- `EffectiveLeverageLimit` - 有效杠杆上限
|
||||
- `ShortBoxPeriod` - 短期箱体周期 (默认72)
|
||||
- `MidBoxPeriod` - 中期箱体周期 (默认240)
|
||||
- `LongBoxPeriod` - 长期箱体周期 (默认500)
|
||||
|
||||
2. **GridInstanceModel** 新增字段:
|
||||
- `CurrentRegimeLevel` - 当前震荡级别 (narrow/standard/wide/volatile)
|
||||
- `ShortBoxUpper/Lower` - 短期箱体上下沿
|
||||
- `MidBoxUpper/Lower` - 中期箱体上下沿
|
||||
- `LongBoxUpper/Lower` - 长期箱体上下沿
|
||||
- `BreakoutStatus` - 突破状态 (none/short/mid/long)
|
||||
- `BreakoutConfirmCount` - 突破确认K线计数
|
||||
|
||||
---
|
||||
|
||||
## 六、风险控制总结
|
||||
|
||||
| 控制点 | 机制 |
|
||||
|--------|------|
|
||||
| 仓位控制 | 根据震荡级别限制总仓位上限 (30-70%) |
|
||||
| 杠杆控制 | 根据震荡级别限制有效杠杆 (2-4x) |
|
||||
| 突破保护 | 三级箱体突破分级处理 |
|
||||
| 假突破恢复 | 50%仓位降级恢复 |
|
||||
| 爆仓预防 | 前端展示爆仓距离,系统自动限制杠杆 |
|
||||
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
1039
docs/plans/2026-03-06-telegram-agent-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
1218
docs/plans/2026-03-06-telegram-bot.md
Normal file
File diff suppressed because it is too large
Load Diff
23
go.mod
23
go.mod
@@ -12,7 +12,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pquerna/otp v1.4.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
@@ -22,10 +21,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
@@ -43,7 +46,12 @@ require (
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -60,15 +68,20 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
@@ -80,6 +93,7 @@ require (
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
@@ -89,14 +103,21 @@ require (
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
113
go.sum
113
go.sum
@@ -1,3 +1,5 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
@@ -8,14 +10,21 @@ github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/S
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
@@ -64,10 +73,20 @@ github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo2
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
@@ -95,8 +114,10 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -133,10 +154,18 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -147,6 +176,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
@@ -162,6 +193,8 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
@@ -170,20 +203,24 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
|
||||
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
@@ -201,6 +238,7 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
@@ -217,12 +255,15 @@ github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -231,6 +272,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -247,53 +289,120 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
106
install-stable.sh
Executable file
106
install-stable.sh
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# NOFX Stable Release Installation Script
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/release/stable/install-stable.sh | bash
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
INSTALL_DIR="${1:-$HOME/nofx}"
|
||||
COMPOSE_FILE="docker-compose.stable.yml"
|
||||
GITHUB_RAW="https://raw.githubusercontent.com/NoFxAiOS/nofx/release/stable"
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ NOFX Stable Release ║"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker daemon is not running.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if docker compose version &> /dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &> /dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
echo -e "${RED}Error: Docker Compose is not available.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Docker ready${NC}"
|
||||
}
|
||||
|
||||
setup_directory() {
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
echo -e "${GREEN}✓ Directory: $INSTALL_DIR${NC}"
|
||||
}
|
||||
|
||||
download_files() {
|
||||
curl -fsSL "$GITHUB_RAW/$COMPOSE_FILE" -o docker-compose.yml
|
||||
echo -e "${GREEN}✓ Config downloaded${NC}"
|
||||
}
|
||||
|
||||
generate_env() {
|
||||
if [ -f ".env" ]; then
|
||||
echo -e "${GREEN}✓ .env exists${NC}"
|
||||
return
|
||||
fi
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
DATA_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null | tr '\n' '\\' | sed 's/\\/\\n/g' | sed 's/\\n$//')
|
||||
cat > .env << EOF
|
||||
NOFX_BACKEND_PORT=8080
|
||||
NOFX_FRONTEND_PORT=3000
|
||||
TZ=Asia/Shanghai
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY}
|
||||
RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY}
|
||||
EOF
|
||||
echo -e "${GREEN}✓ Keys generated${NC}"
|
||||
}
|
||||
|
||||
start_services() {
|
||||
$COMPOSE_CMD pull
|
||||
$COMPOSE_CMD up -d
|
||||
echo -e "${GREEN}✓ Services started${NC}"
|
||||
}
|
||||
|
||||
get_server_ip() {
|
||||
local ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || echo "")
|
||||
echo "${ip:-127.0.0.1}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
local IP=$(get_server_ip)
|
||||
echo ""
|
||||
echo -e "${GREEN}Installation Complete!${NC}"
|
||||
echo -e " Web: http://${IP}:3000"
|
||||
echo -e " API: http://${IP}:8080"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main() {
|
||||
check_docker
|
||||
setup_directory
|
||||
download_files
|
||||
generate_env
|
||||
start_services
|
||||
print_success
|
||||
}
|
||||
|
||||
main
|
||||
@@ -1,6 +1,7 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -8,7 +9,8 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/provider"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"regexp"
|
||||
@@ -119,8 +121,10 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
BTCETHLeverage int `json:"-"`
|
||||
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
|
||||
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
|
||||
BTCETHLeverage int `json:"-"`
|
||||
AltcoinLeverage int `json:"-"`
|
||||
Timeframes []string `json:"-"`
|
||||
}
|
||||
@@ -128,7 +132,8 @@ type Context struct {
|
||||
// Decision AI trading decision
|
||||
type Decision struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
Action string `json:"action"` // Standard: "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
// Grid actions: "place_buy_limit", "place_sell_limit", "cancel_order", "cancel_all_orders", "pause_grid", "resume_grid", "adjust_grid"
|
||||
|
||||
// Opening position parameters
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
@@ -136,6 +141,12 @@ type Decision struct {
|
||||
StopLoss float64 `json:"stop_loss,omitempty"`
|
||||
TakeProfit float64 `json:"take_profit,omitempty"`
|
||||
|
||||
// Grid trading parameters
|
||||
Price float64 `json:"price,omitempty"` // Limit order price (for grid)
|
||||
Quantity float64 `json:"quantity,omitempty"` // Order quantity (for grid)
|
||||
LevelIndex int `json:"level_index,omitempty"` // Grid level index
|
||||
OrderID string `json:"order_id,omitempty"` // Order ID (for cancel)
|
||||
|
||||
// Common parameters
|
||||
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
||||
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
||||
@@ -189,12 +200,23 @@ type OIDeltaData struct {
|
||||
|
||||
// StrategyEngine strategy execution engine
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine
|
||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
||||
return &StrategyEngine{config: config}
|
||||
// Create NofxOS client with API key from config
|
||||
apiKey := config.Indicators.NofxOSAPIKey
|
||||
if apiKey == "" {
|
||||
apiKey = nofxos.DefaultAuthKey
|
||||
}
|
||||
client := nofxos.NewClient(nofxos.DefaultBaseURL, apiKey)
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRiskControlConfig gets risk control configuration
|
||||
@@ -202,6 +224,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
}
|
||||
|
||||
// GetLanguage returns the language from config or falls back to auto-detection
|
||||
func (e *StrategyEngine) GetLanguage() Language {
|
||||
switch e.config.Language {
|
||||
case "zh":
|
||||
return LangChinese
|
||||
case "en":
|
||||
return LangEnglish
|
||||
default:
|
||||
// Fall back to auto-detection from prompt content for backward compatibility
|
||||
return detectLanguage(e.config.PromptSections.RoleDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig gets complete strategy configuration
|
||||
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
||||
return e.config
|
||||
@@ -239,7 +274,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||
oiPositions, err := provider.GetOITopPositions()
|
||||
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||
if err == nil {
|
||||
for _, pos := range oiPositions {
|
||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||
@@ -385,13 +420,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
|
||||
coinSource := e.config.CoinSource
|
||||
|
||||
if coinSource.CoinPoolAPIURL != "" {
|
||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
||||
}
|
||||
if coinSource.OITopAPIURL != "" {
|
||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
||||
}
|
||||
|
||||
switch coinSource.SourceType {
|
||||
case "static":
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
@@ -401,12 +429,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
|
||||
case "coinpool":
|
||||
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
|
||||
if !coinSource.UseCoinPool {
|
||||
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins")
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
|
||||
case "ai500":
|
||||
// 检查 use_ai500 标志,如果为 false 则回退到静态币种
|
||||
if !coinSource.UseAI500 {
|
||||
logger.Infof("⚠️ source_type is 'ai500' but use_ai500 is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
@@ -414,9 +443,14 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
coins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_top":
|
||||
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种
|
||||
@@ -429,15 +463,78 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
return e.getOITopCoins(coinSource.OITopLimit)
|
||||
coins, err := e.getOITopCoins(coinSource.OITopLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "oi_low":
|
||||
// 持仓减少榜,适合做空
|
||||
if !coinSource.UseOILow {
|
||||
logger.Infof("⚠️ source_type is 'oi_low' but use_oi_low is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空列表是正常情况,直接返回
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_all":
|
||||
// All Hyperliquid perp coins
|
||||
if !coinSource.UseHyperAll {
|
||||
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getHyperAllCoins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_main":
|
||||
// Top N Hyperliquid coins by 24h volume
|
||||
if !coinSource.UseHyperMain {
|
||||
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main is false, falling back to static coins")
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"static"},
|
||||
})
|
||||
}
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
}
|
||||
coins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseCoinPool {
|
||||
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err)
|
||||
logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range poolCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
||||
@@ -456,6 +553,39 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseOILow {
|
||||
oiLowCoins, err := e.getOILowCoins(coinSource.OILowLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get OI Low: %v", err)
|
||||
} else {
|
||||
for _, coin := range oiLowCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_low")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseHyperAll {
|
||||
hyperCoins, err := e.getHyperAllCoins()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range hyperCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if coinSource.UseHyperMain {
|
||||
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
|
||||
} else {
|
||||
for _, coin := range hyperMainCoins {
|
||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
@@ -471,19 +601,45 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
Sources: sources,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
return e.filterExcludedCoins(candidates), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
||||
// filterExcludedCoins removes excluded coins from the candidates list
|
||||
func (e *StrategyEngine) filterExcludedCoins(candidates []CandidateCoin) []CandidateCoin {
|
||||
if len(e.config.CoinSource.ExcludedCoins) == 0 {
|
||||
return candidates
|
||||
}
|
||||
|
||||
// Build excluded set for O(1) lookup
|
||||
excluded := make(map[string]bool)
|
||||
for _, coin := range e.config.CoinSource.ExcludedCoins {
|
||||
normalized := market.Normalize(coin)
|
||||
excluded[normalized] = true
|
||||
}
|
||||
|
||||
// Filter out excluded coins
|
||||
filtered := make([]CandidateCoin, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
if !excluded[c.Symbol] {
|
||||
filtered = append(filtered, c)
|
||||
} else {
|
||||
logger.Infof("🚫 Excluded coin: %s", c.Symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getAI500Coins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
symbols, err := provider.GetTopRatedCoins(limit)
|
||||
symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -500,10 +656,10 @@ func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
||||
|
||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
limit = 10
|
||||
}
|
||||
|
||||
positions, err := provider.GetOITopPositions()
|
||||
positions, err := e.nofxosClient.GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -522,6 +678,76 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
positions, err := e.nofxosClient.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for i, pos := range positions {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
symbol := market.Normalize(pos.Symbol)
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"oi_low"},
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// getHyperAllCoins returns all available Hyperliquid perpetual coins
|
||||
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
|
||||
ctx := context.Background()
|
||||
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for _, symbol := range symbols {
|
||||
// Add USDT suffix for compatibility
|
||||
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: normalizedSymbol,
|
||||
Sources: []string{"hyper_all"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
|
||||
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
|
||||
}
|
||||
|
||||
var candidates []CandidateCoin
|
||||
for _, symbol := range symbols {
|
||||
// Add USDT suffix for compatibility
|
||||
normalizedSymbol := market.Normalize(symbol + "USDT")
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: normalizedSymbol,
|
||||
Sources: []string{"hyper_main"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -610,50 +836,82 @@ func extractJSONPath(data interface{}, path string) interface{} {
|
||||
|
||||
// FetchQuantData fetches quantitative data for a single coin
|
||||
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
apiURL := e.config.Indicators.QuantDataAPIURL
|
||||
url := strings.Replace(apiURL, "{symbol}", symbol, -1)
|
||||
// Use nofxos client with unified API key
|
||||
include := "oi,price"
|
||||
if e.config.Indicators.EnableQuantNetflow {
|
||||
include = "netflow,oi,price"
|
||||
}
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(url, 10*time.Second)
|
||||
nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("failed to fetch quant data: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
if nofxosData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Data *QuantData `json:"data"`
|
||||
// Convert nofxos.QuantData to kernel.QuantData
|
||||
quantData := &QuantData{
|
||||
Symbol: nofxosData.Symbol,
|
||||
Price: nofxosData.Price,
|
||||
PriceChange: nofxosData.PriceChange,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
// Convert OI data
|
||||
if nofxosData.OI != nil {
|
||||
quantData.OI = make(map[string]*OIData)
|
||||
for exchange, oiData := range nofxosData.OI {
|
||||
if oiData != nil {
|
||||
kData := &OIData{
|
||||
CurrentOI: oiData.CurrentOI,
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
kData.Delta = make(map[string]*OIDeltaData)
|
||||
for dur, delta := range oiData.Delta {
|
||||
if delta != nil {
|
||||
kData.Delta[dur] = &OIDeltaData{
|
||||
OIDelta: delta.OIDelta,
|
||||
OIDeltaValue: delta.OIDeltaValue,
|
||||
OIDeltaPercent: delta.OIDeltaPercent,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quantData.OI[exchange] = kData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code)
|
||||
// Convert Netflow data
|
||||
if nofxosData.Netflow != nil {
|
||||
quantData.Netflow = &NetflowData{}
|
||||
if nofxosData.Netflow.Institution != nil {
|
||||
quantData.Netflow.Institution = &FlowTypeData{
|
||||
Future: nofxosData.Netflow.Institution.Future,
|
||||
Spot: nofxosData.Netflow.Institution.Spot,
|
||||
}
|
||||
}
|
||||
if nofxosData.Netflow.Personal != nil {
|
||||
quantData.Netflow.Personal = &FlowTypeData{
|
||||
Future: nofxosData.Netflow.Personal.Future,
|
||||
Spot: nofxosData.Netflow.Personal.Spot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return apiResp.Data, nil
|
||||
return quantData, nil
|
||||
}
|
||||
|
||||
// FetchQuantDataBatch batch fetches quantitative data
|
||||
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
||||
result := make(map[string]*QuantData)
|
||||
|
||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -672,28 +930,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
baseURL := indicators.OIRankingAPIURL
|
||||
if baseURL == "" {
|
||||
baseURL = "http://nofxaios.com:30006"
|
||||
}
|
||||
|
||||
// Get auth key from existing API URL or use default
|
||||
authKey := "cm_568c67eae410d912c54c"
|
||||
if indicators.QuantDataAPIURL != "" {
|
||||
if idx := strings.Index(indicators.QuantDataAPIURL, "auth="); idx != -1 {
|
||||
authKey = indicators.QuantDataAPIURL[idx+5:]
|
||||
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
|
||||
authKey = authKey[:ampIdx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
@@ -706,7 +948,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
|
||||
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
||||
|
||||
data, err := provider.GetOIRankingData(baseURL, authKey, duration, limit)
|
||||
data, err := e.nofxosClient.GetOIRanking(duration, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
||||
return nil
|
||||
@@ -718,6 +960,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchNetFlowRankingData fetches market-wide NetFlow ranking data
|
||||
func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableNetFlowRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.NetFlowRankingDuration
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
|
||||
limit := indicators.NetFlowRankingLimit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
logger.Infof("💰 Fetching NetFlow ranking data (duration: %s, limit: %d)", duration, limit)
|
||||
|
||||
data, err := e.nofxosClient.GetNetFlowRanking(duration, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch NetFlow ranking data: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("✓ NetFlow ranking data ready: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d",
|
||||
len(data.InstitutionFutureTop), len(data.InstitutionFutureLow),
|
||||
len(data.PersonalFutureTop), len(data.PersonalFutureLow))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// FetchPriceRankingData fetches market-wide price ranking data (gainers/losers)
|
||||
func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnablePriceRanking {
|
||||
return nil
|
||||
}
|
||||
|
||||
durations := indicators.PriceRankingDuration
|
||||
if durations == "" {
|
||||
durations = "1h"
|
||||
}
|
||||
|
||||
limit := indicators.PriceRankingLimit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
logger.Infof("📈 Fetching Price ranking data (durations: %s, limit: %d)", durations, limit)
|
||||
|
||||
data, err := e.nofxosClient.GetPriceRanking(durations, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Price ranking data: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("✓ Price ranking data ready for %d durations", len(data.Durations))
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Building - System Prompt
|
||||
// ============================================================================
|
||||
@@ -729,7 +1033,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
promptSections := e.config.PromptSections
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := detectLanguage(promptSections.RoleDefinition)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
@@ -920,7 +1224,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop {
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
}
|
||||
|
||||
@@ -976,8 +1280,8 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
|
||||
// Historical trading statistics (helps AI understand past performance)
|
||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||
// Detect language from strategy config
|
||||
lang := detectLanguage(e.config.PromptSections.RoleDefinition)
|
||||
// Get language from strategy config
|
||||
lang := e.GetLanguage()
|
||||
|
||||
// Win/Loss ratio
|
||||
var winLossRatio float64
|
||||
@@ -1081,9 +1385,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Get language for market data formatting
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if e.GetLanguage() == LangChinese {
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
|
||||
// OI Ranking data (market-wide open interest changes)
|
||||
if ctx.OIRankingData != nil {
|
||||
sb.WriteString(provider.FormatOIRankingForAI(ctx.OIRankingData))
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// NetFlow Ranking data (market-wide fund flow)
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatNetFlowRankingForAI(ctx.NetFlowRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
// Price Ranking data (market-wide gainers/losers)
|
||||
if ctx.PriceRankingData != nil {
|
||||
sb.WriteString(nofxos.FormatPriceRankingForAI(ctx.PriceRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
@@ -1134,15 +1454,56 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
|
||||
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
if len(sources) > 1 {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
// 多信号源组合
|
||||
hasAI500 := false
|
||||
hasOITop := false
|
||||
hasOILow := false
|
||||
hasHyperAll := false
|
||||
hasHyperMain := false
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = true
|
||||
case "hyper_all":
|
||||
hasHyperAll = true
|
||||
case "hyper_main":
|
||||
hasHyperMain = true
|
||||
}
|
||||
}
|
||||
if hasAI500 && hasOITop {
|
||||
return " (AI500+OI_Top dual signal)"
|
||||
}
|
||||
if hasAI500 && hasOILow {
|
||||
return " (AI500+OI_Low dual signal)"
|
||||
}
|
||||
if hasOITop && hasOILow {
|
||||
return " (OI_Top+OI_Low)"
|
||||
}
|
||||
if hasHyperMain && hasAI500 {
|
||||
return " (HyperMain+AI500)"
|
||||
}
|
||||
if hasHyperAll || hasHyperMain {
|
||||
return " (Hyperliquid)"
|
||||
}
|
||||
return " (Multiple sources)"
|
||||
} else if len(sources) == 1 {
|
||||
switch sources[0] {
|
||||
case "ai500":
|
||||
return " (AI500)"
|
||||
case "oi_top":
|
||||
return " (OI_Top position growth)"
|
||||
return " (OI_Top 持仓增加)"
|
||||
case "oi_low":
|
||||
return " (OI_Low 持仓减少)"
|
||||
case "static":
|
||||
return " (Manual selection)"
|
||||
case "hyper_all":
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -1612,8 +1973,8 @@ func compactArrayOpen(s string) string {
|
||||
// ============================================================================
|
||||
|
||||
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||
for i, decision := range decisions {
|
||||
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
for i := range decisions {
|
||||
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
|
||||
// 7. OI排名数据(如果有)
|
||||
if ctx.OIRankingData != nil {
|
||||
nofxosLang := nofxos.LangEnglish
|
||||
if lang == LangChinese {
|
||||
sb.WriteString(formatOIRankingZH(ctx.OIRankingData))
|
||||
} else {
|
||||
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
|
||||
nofxosLang = nofxos.LangChinese
|
||||
}
|
||||
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIRankingZH 格式化OI排名数据(中文)
|
||||
func formatOIRankingZH(oiData interface{}) string {
|
||||
// TODO: 根据实际OIRankingData结构实现
|
||||
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
|
||||
}
|
||||
|
||||
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
@@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIRankingEN 格式化OI排名数据(英文)
|
||||
func formatOIRankingEN(oiData interface{}) string {
|
||||
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
|
||||
}
|
||||
|
||||
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||
618
kernel/grid_engine.go
Normal file
618
kernel/grid_engine.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Grid Trading Context and Types
|
||||
// ============================================================================
|
||||
|
||||
// GridLevelInfo represents a single grid level's current state
|
||||
type GridLevelInfo struct {
|
||||
Index int `json:"index"` // Level index (0 = lowest)
|
||||
Price float64 `json:"price"` // Target price for this level
|
||||
State string `json:"state"` // "empty", "pending", "filled"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderID string `json:"order_id"` // Current order ID (if pending)
|
||||
OrderQuantity float64 `json:"order_quantity"` // Order quantity
|
||||
PositionSize float64 `json:"position_size"` // Position size (if filled)
|
||||
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
|
||||
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
|
||||
}
|
||||
|
||||
// GridContext contains all information needed for AI grid decision making
|
||||
type GridContext struct {
|
||||
// Basic info
|
||||
Symbol string `json:"symbol"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
|
||||
// Grid configuration
|
||||
GridCount int `json:"grid_count"`
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
Leverage int `json:"leverage"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
GridSpacing float64 `json:"grid_spacing"`
|
||||
Distribution string `json:"distribution"`
|
||||
|
||||
// Grid state
|
||||
Levels []GridLevelInfo `json:"levels"`
|
||||
ActiveOrderCount int `json:"active_order_count"`
|
||||
FilledLevelCount int `json:"filled_level_count"`
|
||||
IsPaused bool `json:"is_paused"`
|
||||
|
||||
// Market data
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerUpper float64 `json:"bollinger_upper"`
|
||||
BollingerMiddle float64 `json:"bollinger_middle"`
|
||||
BollingerLower float64 `json:"bollinger_lower"`
|
||||
BollingerWidth float64 `json:"bollinger_width"` // Percentage
|
||||
EMA20 float64 `json:"ema20"`
|
||||
EMA50 float64 `json:"ema50"`
|
||||
EMADistance float64 `json:"ema_distance"` // Percentage
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
MACD float64 `json:"macd"`
|
||||
MACDSignal float64 `json:"macd_signal"`
|
||||
MACDHistogram float64 `json:"macd_histogram"`
|
||||
FundingRate float64 `json:"funding_rate"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
PriceChange1h float64 `json:"price_change_1h"`
|
||||
PriceChange4h float64 `json:"price_change_4h"`
|
||||
|
||||
// Account info
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
AvailableBalance float64 `json:"available_balance"`
|
||||
CurrentPosition float64 `json:"current_position"` // Net position size
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"`
|
||||
|
||||
// Performance
|
||||
TotalProfit float64 `json:"total_profit"`
|
||||
TotalTrades int `json:"total_trades"`
|
||||
WinningTrades int `json:"winning_trades"`
|
||||
MaxDrawdown float64 `json:"max_drawdown"`
|
||||
DailyPnL float64 `json:"daily_pnl"`
|
||||
|
||||
// Box indicators (Donchian Channels)
|
||||
BoxData *market.BoxData `json:"box_data,omitempty"`
|
||||
|
||||
// Grid direction (neutral, long, short, long_bias, short_bias)
|
||||
CurrentDirection string `json:"current_direction,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Prompt Building
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridSystemPrompt builds the system prompt for grid trading AI
|
||||
func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridSystemPromptZh(config)
|
||||
}
|
||||
return buildGridSystemPromptEn(config)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# 你是一个专业的网格交易AI
|
||||
|
||||
## 角色定义
|
||||
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
|
||||
1. 判断当前市场状态(震荡/趋势/高波动)
|
||||
2. 决定是否需要调整网格或暂停交易
|
||||
3. 管理每个网格层级的订单
|
||||
|
||||
## 网格配置
|
||||
- 交易对: %s
|
||||
- 网格层数: %d
|
||||
- 总投资: %.2f USDT
|
||||
- 杠杆: %dx
|
||||
- 价格分布: %s
|
||||
|
||||
## 决策规则
|
||||
|
||||
### 市场状态判断
|
||||
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
|
||||
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
|
||||
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
|
||||
|
||||
### 可执行的操作
|
||||
- place_buy_limit: 在指定价格下买入限价单
|
||||
- place_sell_limit: 在指定价格下卖出限价单
|
||||
- cancel_order: 取消指定订单
|
||||
- cancel_all_orders: 取消所有订单
|
||||
- pause_grid: 暂停网格交易(趋势市场时)
|
||||
- resume_grid: 恢复网格交易(震荡市场时)
|
||||
- adjust_grid: 调整网格边界
|
||||
- hold: 保持当前状态不操作
|
||||
|
||||
## 输出格式
|
||||
输出JSON数组,每个决策包含:
|
||||
- symbol: 交易对
|
||||
- action: 操作类型
|
||||
- price: 价格(限价单用)
|
||||
- quantity: 数量
|
||||
- level_index: 网格层级索引
|
||||
- order_id: 订单ID(取消订单用)
|
||||
- confidence: 置信度 0-100
|
||||
- reasoning: 决策理由
|
||||
|
||||
示例:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近,下买单"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
func buildGridSystemPromptEn(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# You are a Professional Grid Trading AI
|
||||
|
||||
## Role Definition
|
||||
You are an experienced grid trading expert managing a grid strategy for %s. Your tasks are:
|
||||
1. Assess current market regime (ranging/trending/volatile)
|
||||
2. Decide whether to adjust grid or pause trading
|
||||
3. Manage orders at each grid level
|
||||
|
||||
## Grid Configuration
|
||||
- Symbol: %s
|
||||
- Grid Levels: %d
|
||||
- Total Investment: %.2f USDT
|
||||
- Leverage: %dx
|
||||
- Distribution: %s
|
||||
|
||||
## Decision Rules
|
||||
|
||||
### Market Regime Assessment
|
||||
- **Ranging Market** (ideal for grid): Bollinger width < 3%%, EMA20/50 distance < 1%%, price near middle band
|
||||
- **Trending Market** (pause grid): Bollinger width > 4%%, EMA20/50 distance > 2%%, price breaking bands
|
||||
- **High Volatility** (caution): ATR spike, erratic price movement
|
||||
|
||||
### Available Actions
|
||||
- place_buy_limit: Place buy limit order at specified price
|
||||
- place_sell_limit: Place sell limit order at specified price
|
||||
- cancel_order: Cancel specific order
|
||||
- cancel_all_orders: Cancel all orders
|
||||
- pause_grid: Pause grid trading (in trending market)
|
||||
- resume_grid: Resume grid trading (in ranging market)
|
||||
- adjust_grid: Adjust grid boundaries
|
||||
- hold: Maintain current state
|
||||
|
||||
## Output Format
|
||||
Output JSON array, each decision contains:
|
||||
- symbol: Trading pair
|
||||
- action: Action type
|
||||
- price: Price (for limit orders)
|
||||
- quantity: Quantity
|
||||
- level_index: Grid level index
|
||||
- order_id: Order ID (for cancel)
|
||||
- confidence: Confidence 0-100
|
||||
- reasoning: Decision reason
|
||||
|
||||
Example:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price approaching, place buy order"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market ranging, maintain current grid"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
|
||||
// BuildGridUserPrompt builds the user prompt with current grid context
|
||||
func BuildGridUserPrompt(ctx *GridContext, lang string) string {
|
||||
if lang == "zh" {
|
||||
return buildGridUserPromptZh(ctx)
|
||||
}
|
||||
return buildGridUserPromptEn(ctx)
|
||||
}
|
||||
|
||||
func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## 市场数据\n")
|
||||
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
|
||||
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
|
||||
sb.WriteString("|----------|------|------|------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## 账户状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## 网格状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## 网格层级详情\n")
|
||||
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
|
||||
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## 绩效统计\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
|
||||
sb.WriteString("输出JSON数组格式的决策列表。\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildGridUserPromptEn(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## Market Data\n")
|
||||
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## Box Indicators (Donchian Channels)\n\n")
|
||||
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
|
||||
sb.WriteString("|-----------|-------|-------|-------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
midWidth := 0.0
|
||||
longWidth := 0.0
|
||||
|
||||
if ctx.BoxData.CurrentPrice > 0 {
|
||||
shortWidth = (ctx.BoxData.ShortUpper - ctx.BoxData.ShortLower) / ctx.BoxData.CurrentPrice * 100
|
||||
midWidth = (ctx.BoxData.MidUpper - ctx.BoxData.MidLower) / ctx.BoxData.CurrentPrice * 100
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ BREAKOUT: Price outside long-term box!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ WARNING: Price approaching long-term box boundary\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## Account Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## Grid Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescEn := map[string]string{
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long Bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short Bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescEn[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## Grid Levels Detail\n")
|
||||
sb.WriteString("| Level | Price | State | Side | Order Qty | Position | Unrealized PnL |\n")
|
||||
sb.WriteString("|-------|-------|-------|------|-----------|----------|----------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
level.Index, level.Price, level.State, level.Side,
|
||||
level.OrderQuantity, level.PositionSize, level.UnrealizedPnL))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## Performance Stats\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- Daily PnL: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## Please analyze the data above and make grid trading decisions\n")
|
||||
sb.WriteString("Output a JSON array of decisions.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Decision Functions
|
||||
// ============================================================================
|
||||
|
||||
// GetGridDecisions gets AI decisions for grid trading
|
||||
func GetGridDecisions(ctx *GridContext, mcpClient mcp.AIClient, config *store.GridStrategyConfig, lang string) (*FullDecision, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Build prompts
|
||||
systemPrompt := BuildGridSystemPrompt(config, lang)
|
||||
userPrompt := BuildGridUserPrompt(ctx, lang)
|
||||
|
||||
logger.Infof("🤖 [Grid] Calling AI for grid decisions...")
|
||||
|
||||
// Call AI
|
||||
response, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AI call failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse decisions from response
|
||||
decisions, err := parseGridDecisions(response, ctx.Symbol)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse grid decisions: %v", err)
|
||||
// Return hold decision as fallback
|
||||
decisions = []Decision{{
|
||||
Symbol: ctx.Symbol,
|
||||
Action: "hold",
|
||||
Confidence: 50,
|
||||
Reasoning: "Failed to parse AI response, holding current state",
|
||||
}}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Milliseconds()
|
||||
logger.Infof("⏱️ [Grid] AI call duration: %d ms, decisions: %d", duration, len(decisions))
|
||||
|
||||
// Extract chain of thought from response
|
||||
cotTrace := extractCoTTrace(response)
|
||||
|
||||
return &FullDecision{
|
||||
SystemPrompt: systemPrompt,
|
||||
UserPrompt: userPrompt,
|
||||
CoTTrace: cotTrace,
|
||||
Decisions: decisions,
|
||||
RawResponse: response,
|
||||
AIRequestDurationMs: duration,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseGridDecisions parses AI response into grid decisions
|
||||
func parseGridDecisions(response string, symbol string) ([]Decision, error) {
|
||||
// Try to find JSON array in response
|
||||
jsonStr := extractJSONArray(response)
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("no JSON array found in response")
|
||||
}
|
||||
|
||||
var decisions []Decision
|
||||
if err := json.Unmarshal([]byte(jsonStr), &decisions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
// Validate and set default symbol
|
||||
for i := range decisions {
|
||||
if decisions[i].Symbol == "" {
|
||||
decisions[i].Symbol = symbol
|
||||
}
|
||||
// Validate action
|
||||
if !isValidGridAction(decisions[i].Action) {
|
||||
logger.Warnf("Invalid grid action: %s", decisions[i].Action)
|
||||
}
|
||||
}
|
||||
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// extractJSONArray extracts JSON array from AI response
|
||||
func extractJSONArray(response string) string {
|
||||
// Try to find ```json code block first
|
||||
matches := reJSONFence.FindStringSubmatch(response)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
// Try to find raw JSON array
|
||||
matches = reJSONArray.FindStringSubmatch(response)
|
||||
if len(matches) > 0 {
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidGridAction checks if action is a valid grid action
|
||||
func isValidGridAction(action string) bool {
|
||||
validActions := map[string]bool{
|
||||
"place_buy_limit": true,
|
||||
"place_sell_limit": true,
|
||||
"cancel_order": true,
|
||||
"cancel_all_orders": true,
|
||||
"pause_grid": true,
|
||||
"resume_grid": true,
|
||||
"adjust_grid": true,
|
||||
"hold": true,
|
||||
// Also support standard actions for compatibility
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
}
|
||||
return validActions[action]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grid Context Builder Helpers
|
||||
// ============================================================================
|
||||
|
||||
// BuildGridContextFromMarketData builds grid context from market data
|
||||
func BuildGridContextFromMarketData(mktData *market.Data, config *store.GridStrategyConfig) *GridContext {
|
||||
ctx := &GridContext{
|
||||
Symbol: config.Symbol,
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
CurrentPrice: mktData.CurrentPrice,
|
||||
|
||||
// Grid config
|
||||
GridCount: config.GridCount,
|
||||
TotalInvestment: config.TotalInvestment,
|
||||
Leverage: config.Leverage,
|
||||
Distribution: config.Distribution,
|
||||
|
||||
// Market data
|
||||
PriceChange1h: mktData.PriceChange1h,
|
||||
PriceChange4h: mktData.PriceChange4h,
|
||||
FundingRate: mktData.FundingRate,
|
||||
}
|
||||
|
||||
// Extract indicators from timeframe data
|
||||
if mktData.TimeframeData != nil {
|
||||
if tf5m, ok := mktData.TimeframeData["5m"]; ok {
|
||||
if len(tf5m.BOLLUpper) > 0 {
|
||||
ctx.BollingerUpper = tf5m.BOLLUpper[len(tf5m.BOLLUpper)-1]
|
||||
ctx.BollingerMiddle = tf5m.BOLLMiddle[len(tf5m.BOLLMiddle)-1]
|
||||
ctx.BollingerLower = tf5m.BOLLLower[len(tf5m.BOLLLower)-1]
|
||||
if ctx.BollingerMiddle > 0 {
|
||||
ctx.BollingerWidth = (ctx.BollingerUpper - ctx.BollingerLower) / ctx.BollingerMiddle * 100
|
||||
}
|
||||
}
|
||||
ctx.ATR14 = tf5m.ATR14
|
||||
if len(tf5m.RSI14Values) > 0 {
|
||||
ctx.RSI14 = tf5m.RSI14Values[len(tf5m.RSI14Values)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract longer term context
|
||||
if mktData.LongerTermContext != nil {
|
||||
if ctx.ATR14 == 0 {
|
||||
ctx.ATR14 = mktData.LongerTermContext.ATR14
|
||||
}
|
||||
ctx.EMA50 = mktData.LongerTermContext.EMA50
|
||||
}
|
||||
|
||||
ctx.EMA20 = mktData.CurrentEMA20
|
||||
ctx.MACD = mktData.CurrentMACD
|
||||
|
||||
// Calculate EMA distance
|
||||
if ctx.EMA50 > 0 {
|
||||
ctx.EMADistance = (ctx.EMA20 - ctx.EMA50) / ctx.EMA50 * 100
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Helper function for max
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -1,6 +1,4 @@
|
||||
package decision
|
||||
|
||||
import "fmt"
|
||||
package kernel
|
||||
|
||||
// ============================================================================
|
||||
// Trading Data Schema - 交易数据字典
|
||||
@@ -481,18 +479,6 @@ func getSchemaPromptZH() string {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 交易规则
|
||||
prompt += "\n## ⚖️ 交易规则\n\n"
|
||||
prompt += "### 风险管理\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### 出场信号\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
// OI解读
|
||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
@@ -500,14 +486,6 @@ func getSchemaPromptZH() string {
|
||||
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
|
||||
// 常见错误
|
||||
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
|
||||
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
|
||||
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
|
||||
prompt += formatFieldDefEN(key, field)
|
||||
}
|
||||
|
||||
// Trading Rules
|
||||
prompt += "\n## ⚖️ Trading Rules\n\n"
|
||||
prompt += "### Risk Management\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### Exit Signals\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
// OI Interpretation
|
||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
|
||||
@@ -559,14 +525,6 @@ func getSchemaPromptEN() string {
|
||||
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
|
||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
|
||||
|
||||
// Common Mistakes
|
||||
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
|
||||
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
|
||||
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -147,10 +147,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
||||
"交易指标",
|
||||
"持仓指标",
|
||||
"市场数据",
|
||||
"交易规则",
|
||||
"风险管理",
|
||||
"持仓量(OI)变化解读",
|
||||
"常见错误",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
@@ -174,10 +171,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
||||
"Trade Metrics",
|
||||
"Position Metrics",
|
||||
"Market Data",
|
||||
"Trading Rules",
|
||||
"Risk Management",
|
||||
"Open Interest",
|
||||
"Common Mistakes",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
@@ -1,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
12
main.go
12
main.go
@@ -11,6 +11,7 @@ import (
|
||||
"nofx/manager"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -78,7 +79,7 @@ func main() {
|
||||
logger.Fatalf("❌ Failed to initialize database: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
backtest.UseDatabase(st.DB())
|
||||
backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
|
||||
|
||||
// Initialize installation ID for experience improvement (anonymous statistics)
|
||||
initInstallationID(st)
|
||||
@@ -130,12 +131,21 @@ func main() {
|
||||
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, backtestManager, cfg.APIServerPort)
|
||||
|
||||
// Create hot-reload channel for Telegram bot; wire it to the API server
|
||||
// so that POST /api/telegram can trigger a bot restart when the token changes.
|
||||
telegramReloadCh := make(chan struct{}, 1)
|
||||
server.SetTelegramReloadCh(telegramReloadCh)
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"nofx/debate"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
@@ -19,7 +19,7 @@ type TraderExecutorAdapter struct {
|
||||
}
|
||||
|
||||
// ExecuteDecision executes a trading decision
|
||||
func (a *TraderExecutorAdapter) ExecuteDecision(d *decision.Decision) error {
|
||||
func (a *TraderExecutorAdapter) ExecuteDecision(d *kernel.Decision) error {
|
||||
return a.autoTrader.ExecuteDecision(d)
|
||||
}
|
||||
|
||||
@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
// Concurrently fetch data for each trader
|
||||
for i, t := range traders {
|
||||
go func(index int, trader *trader.AutoTrader) {
|
||||
// Set timeout to 3 seconds for single trader
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use channel for timeout control
|
||||
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case err := <-errorChan:
|
||||
// Failed to get account info
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err)
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID())
|
||||
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -407,14 +407,20 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
// RemoveTrader removes a trader from memory (does not affect database)
|
||||
// Used to force reload when updating trader configuration
|
||||
// If the trader is running, it will be stopped first
|
||||
func (tm *TraderManager) RemoveTrader(traderID string) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
if _, exists := tm.traders[traderID]; exists {
|
||||
if t, exists := tm.traders[traderID]; exists {
|
||||
// Stop the trader if it's running (this ensures the goroutine exits)
|
||||
status := t.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
logger.Infof("⏹ Stopping trader %s before removing from memory...", traderID)
|
||||
t.Stop()
|
||||
}
|
||||
delete(tm.traders, traderID)
|
||||
logger.Infof("✓ Trader %s removed from memory", traderID)
|
||||
}
|
||||
@@ -606,7 +612,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to TraderManager (coinPoolURL/oiTopURL already obtained from strategy config)
|
||||
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
|
||||
@@ -641,7 +647,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
||||
}
|
||||
|
||||
// Build AutoTraderConfig (coinPoolURL/oiTopURL obtained from strategy config, used in StrategyEngine)
|
||||
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
|
||||
traderConfig := trader.AutoTraderConfig{
|
||||
ID: traderCfg.ID,
|
||||
Name: traderCfg.Name,
|
||||
@@ -657,13 +663,16 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
QwenKey: "",
|
||||
CustomAPIURL: aiModelCfg.CustomAPIURL,
|
||||
CustomModelName: aiModelCfg.CustomModelName,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
|
||||
InitialBalance: traderCfg.InitialBalance,
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
traderCfg.Name, traderCfg.ScanIntervalMinutes, traderConfig.ScanInterval)
|
||||
|
||||
// Set API keys based on exchange type (convert EncryptedString to string)
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance":
|
||||
@@ -680,9 +689,17 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.BitgetAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.BitgetSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.BitgetPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "gate":
|
||||
traderConfig.GateAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.GateSecretKey = string(exchangeCfg.SecretKey)
|
||||
case "kucoin":
|
||||
traderConfig.KuCoinAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.KuCoinSecretKey = string(exchangeCfg.SecretKey)
|
||||
traderConfig.KuCoinPassphrase = string(exchangeCfg.Passphrase)
|
||||
case "hyperliquid":
|
||||
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
|
||||
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
|
||||
case "aster":
|
||||
traderConfig.AsterUser = exchangeCfg.AsterUser
|
||||
traderConfig.AsterSigner = exchangeCfg.AsterSigner
|
||||
@@ -693,6 +710,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.LighterAPIKeyPrivateKey = string(exchangeCfg.LighterAPIKeyPrivateKey)
|
||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||
case "indodax":
|
||||
traderConfig.IndodaxAPIKey = string(exchangeCfg.APIKey)
|
||||
traderConfig.IndodaxSecretKey = string(exchangeCfg.SecretKey)
|
||||
}
|
||||
|
||||
// Set API keys based on AI model (convert EncryptedString to string)
|
||||
|
||||
158
market/data.go
158
market/data.go
@@ -31,7 +31,7 @@ var (
|
||||
// Note: Kline data now uses free/open API (coinank_api.Kline) which doesn't require authentication
|
||||
|
||||
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline, error) {
|
||||
// Map interval string to coinank enum
|
||||
var coinankInterval coinank_enum.Interval
|
||||
switch interval {
|
||||
@@ -67,13 +67,44 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
||||
}
|
||||
|
||||
// Map exchange string to coinank enum
|
||||
var coinankExchange coinank_enum.Exchange
|
||||
switch strings.ToLower(exchange) {
|
||||
case "binance":
|
||||
coinankExchange = coinank_enum.Binance
|
||||
case "bybit":
|
||||
coinankExchange = coinank_enum.Bybit
|
||||
case "okx":
|
||||
coinankExchange = coinank_enum.Okex
|
||||
case "bitget":
|
||||
coinankExchange = coinank_enum.Bitget
|
||||
case "gate":
|
||||
coinankExchange = coinank_enum.Gate
|
||||
case "hyperliquid":
|
||||
coinankExchange = coinank_enum.Hyperliquid
|
||||
case "aster":
|
||||
coinankExchange = coinank_enum.Aster
|
||||
default:
|
||||
// Default to Binance for unknown exchanges
|
||||
coinankExchange = coinank_enum.Binance
|
||||
}
|
||||
|
||||
// Call CoinAnk free/open API (no authentication required)
|
||||
ctx := context.Background()
|
||||
ts := time.Now().UnixMilli()
|
||||
// Use "To" side to search backward from current time (get historical klines)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
coinankKlines, err := coinank_api.Kline(ctx, symbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
// If exchange-specific data fails, fallback to Binance
|
||||
if coinankExchange != coinank_enum.Binance {
|
||||
logger.Warnf("⚠️ CoinAnk %s data failed, falling back to Binance: %v", exchange, err)
|
||||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CoinAnk API error (fallback): %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert coinank kline format to market.Kline format
|
||||
@@ -134,8 +165,13 @@ func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, erro
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
// Get retrieves market data for the specified token
|
||||
// Get retrieves market data for the specified token (uses Binance data by default)
|
||||
func Get(symbol string) (*Data, error) {
|
||||
return GetWithExchange(symbol, "binance")
|
||||
}
|
||||
|
||||
// GetWithExchange retrieves market data for the specified token using exchange-specific data
|
||||
func GetWithExchange(symbol, exchange string) (*Data, error) {
|
||||
var klines3m, klines4h []Kline
|
||||
var err error
|
||||
// Normalize symbol
|
||||
@@ -144,18 +180,21 @@ func Get(symbol string) (*Data, error) {
|
||||
// Check if this is an xyz dex asset (use Hyperliquid API)
|
||||
isXyzAsset := IsXyzDexAsset(symbol)
|
||||
|
||||
// For hyperliquid exchange, also use Hyperliquid API
|
||||
useHyperliquidAPI := isXyzAsset || strings.ToLower(exchange) == "hyperliquid"
|
||||
|
||||
// Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available)
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
// Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available)
|
||||
klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
||||
// Use CoinAnk for regular crypto assets with exchange-specific data
|
||||
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +205,15 @@ func Get(symbol string) (*Data, error) {
|
||||
}
|
||||
|
||||
// Get 4-hour K-line data
|
||||
if isXyzAsset {
|
||||
if useHyperliquidAPI {
|
||||
klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err)
|
||||
}
|
||||
} else {
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
||||
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", exchange, 100)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
||||
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk (%s): %v", exchange, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,8 +329,8 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Use CoinAnk for regular crypto assets
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, 200)
|
||||
// Use CoinAnk for regular crypto assets (default to Binance)
|
||||
klines, err = getKlinesFromCoinAnk(symbol, tf, "binance", 200)
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||
continue
|
||||
@@ -1068,6 +1107,11 @@ func Normalize(symbol string) string {
|
||||
return "xyz:" + base
|
||||
}
|
||||
|
||||
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||||
symbol = strings.ReplaceAll(symbol, "_", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-SWAP", "")
|
||||
symbol = strings.ReplaceAll(symbol, "-", "")
|
||||
|
||||
// For regular crypto assets
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
return symbol
|
||||
@@ -1210,3 +1254,91 @@ func ExportCalculateATR(klines []Kline, period int) float64 {
|
||||
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||
return calculateBOLL(klines, period, multiplier)
|
||||
}
|
||||
|
||||
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
|
||||
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
|
||||
if len(klines) == 0 || period <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Use all available klines if period > len(klines)
|
||||
start := len(klines) - period
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
upper = klines[start].High
|
||||
lower = klines[start].Low
|
||||
|
||||
for i := start + 1; i < len(klines); i++ {
|
||||
if klines[i].High > upper {
|
||||
upper = klines[i].High
|
||||
}
|
||||
if klines[i].Low < lower {
|
||||
lower = klines[i].Low
|
||||
}
|
||||
}
|
||||
|
||||
return upper, lower
|
||||
}
|
||||
|
||||
// ExportCalculateDonchian exports calculateDonchian for testing
|
||||
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
|
||||
return calculateDonchian(klines, period)
|
||||
}
|
||||
|
||||
// Box period constants (in 1h candles)
|
||||
const (
|
||||
ShortBoxPeriod = 72 // 3 days of 1h candles
|
||||
MidBoxPeriod = 240 // 10 days of 1h candles
|
||||
LongBoxPeriod = 500 // ~21 days of 1h candles
|
||||
)
|
||||
|
||||
// calculateBoxData calculates multi-period box data from klines
|
||||
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
box := &BoxData{
|
||||
CurrentPrice: currentPrice,
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return box
|
||||
}
|
||||
|
||||
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
|
||||
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
|
||||
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
// ExportCalculateBoxData exports calculateBoxData for testing
|
||||
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
return calculateBoxData(klines, currentPrice)
|
||||
}
|
||||
|
||||
// GetBoxData fetches 1h klines and calculates box data for a symbol
|
||||
func GetBoxData(symbol string) (*BoxData, error) {
|
||||
symbol = Normalize(symbol)
|
||||
|
||||
// Fetch 500 1h klines
|
||||
var klines []Kline
|
||||
var err error
|
||||
|
||||
if IsXyzDexAsset(symbol) {
|
||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||
} else {
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", "binance", LongBoxPeriod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return nil, fmt.Errorf("no kline data available")
|
||||
}
|
||||
|
||||
currentPrice := klines[len(klines)-1].Close
|
||||
|
||||
return calculateBoxData(klines, currentPrice), nil
|
||||
}
|
||||
|
||||
@@ -500,3 +500,86 @@ func TestIsStaleData_EmptyKlines(t *testing.T) {
|
||||
t.Error("Expected false for empty klines, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian(t *testing.T) {
|
||||
// Create test klines with known high/low values
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
{High: 102, Low: 92},
|
||||
{High: 108, Low: 85},
|
||||
{High: 103, Low: 91},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 5)
|
||||
|
||||
if upper != 108 {
|
||||
t.Errorf("Expected upper = 108, got %v", upper)
|
||||
}
|
||||
if lower != 85 {
|
||||
t.Errorf("Expected lower = 85, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_PartialPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 10)
|
||||
|
||||
// Should use all available klines when period > len(klines)
|
||||
if upper != 105 {
|
||||
t.Errorf("Expected upper = 105, got %v", upper)
|
||||
}
|
||||
if lower != 88 {
|
||||
t.Errorf("Expected lower = 88, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_InvalidPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
}
|
||||
|
||||
// Zero period should return (0, 0)
|
||||
upper, lower := ExportCalculateDonchian(klines, 0)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for zero period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
|
||||
// Negative period should return (0, 0)
|
||||
upper, lower = ExportCalculateDonchian(klines, -1)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for negative period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBoxData(t *testing.T) {
|
||||
// Create synthetic kline data
|
||||
klines := make([]Kline, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
basePrice := 100.0
|
||||
klines[i] = Kline{
|
||||
High: basePrice + float64(i%10),
|
||||
Low: basePrice - float64(i%10),
|
||||
Close: basePrice,
|
||||
}
|
||||
}
|
||||
|
||||
box := ExportCalculateBoxData(klines, 100.0)
|
||||
|
||||
if box.ShortUpper == 0 || box.ShortLower == 0 {
|
||||
t.Error("Short box should not be zero")
|
||||
}
|
||||
if box.MidUpper == 0 || box.MidLower == 0 {
|
||||
t.Error("Mid box should not be zero")
|
||||
}
|
||||
if box.LongUpper == 0 || box.LongLower == 0 {
|
||||
t.Error("Long box should not be zero")
|
||||
}
|
||||
if box.CurrentPrice != 100.0 {
|
||||
t.Errorf("Expected CurrentPrice = 100.0, got %v", box.CurrentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +187,76 @@ var config = Config{
|
||||
},
|
||||
UpdateInterval: 60, // 1 minute
|
||||
}
|
||||
|
||||
// BoxData represents multi-period Donchian channel (box) data
|
||||
type BoxData struct {
|
||||
// Short-term box (72 1h candles = 3 days)
|
||||
ShortUpper float64 `json:"short_upper"`
|
||||
ShortLower float64 `json:"short_lower"`
|
||||
|
||||
// Mid-term box (240 1h candles = 10 days)
|
||||
MidUpper float64 `json:"mid_upper"`
|
||||
MidLower float64 `json:"mid_lower"`
|
||||
|
||||
// Long-term box (500 1h candles = ~21 days)
|
||||
LongUpper float64 `json:"long_upper"`
|
||||
LongLower float64 `json:"long_lower"`
|
||||
|
||||
// Current price position relative to boxes
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
}
|
||||
|
||||
// RegimeLevel represents the ranging classification level
|
||||
type RegimeLevel string
|
||||
|
||||
const (
|
||||
RegimeLevelNarrow RegimeLevel = "narrow" // 窄幅震荡
|
||||
RegimeLevelStandard RegimeLevel = "standard" // 标准震荡
|
||||
RegimeLevelWide RegimeLevel = "wide" // 宽幅震荡
|
||||
RegimeLevelVolatile RegimeLevel = "volatile" // 剧烈震荡
|
||||
RegimeLevelTrending RegimeLevel = "trending" // 趋势
|
||||
)
|
||||
|
||||
// BreakoutLevel represents which box level has been broken
|
||||
type BreakoutLevel string
|
||||
|
||||
const (
|
||||
BreakoutNone BreakoutLevel = "none"
|
||||
BreakoutShort BreakoutLevel = "short"
|
||||
BreakoutMid BreakoutLevel = "mid"
|
||||
BreakoutLong BreakoutLevel = "long"
|
||||
)
|
||||
|
||||
// GridDirection represents the current grid trading direction bias
|
||||
type GridDirection string
|
||||
|
||||
const (
|
||||
GridDirectionNeutral GridDirection = "neutral" // 50% buy + 50% sell
|
||||
GridDirectionLong GridDirection = "long" // 100% buy
|
||||
GridDirectionShort GridDirection = "short" // 100% sell
|
||||
GridDirectionLongBias GridDirection = "long_bias" // 70% buy + 30% sell (default)
|
||||
GridDirectionShortBias GridDirection = "short_bias" // 30% buy + 70% sell (default)
|
||||
)
|
||||
|
||||
// GetBuySellRatio returns the buy and sell ratio for this direction
|
||||
// biasRatio is the ratio for biased directions (default 0.7 means 70%/30%)
|
||||
func (d GridDirection) GetBuySellRatio(biasRatio float64) (buyRatio, sellRatio float64) {
|
||||
if biasRatio <= 0 || biasRatio > 1 {
|
||||
biasRatio = 0.7 // Default 70%/30%
|
||||
}
|
||||
|
||||
switch d {
|
||||
case GridDirectionNeutral:
|
||||
return 0.5, 0.5
|
||||
case GridDirectionLong:
|
||||
return 1.0, 0.0
|
||||
case GridDirectionShort:
|
||||
return 0.0, 1.0
|
||||
case GridDirectionLongBias:
|
||||
return biasRatio, 1.0 - biasRatio
|
||||
case GridDirectionShortBias:
|
||||
return 1.0 - biasRatio, biasRatio
|
||||
default:
|
||||
return 0.5, 0.5
|
||||
}
|
||||
}
|
||||
|
||||
345
mcp/blockrun_base.go
Normal file
345
mcp/blockrun_base.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderBlockRunBase = "blockrun-base"
|
||||
DefaultBlockRunBaseURL = "https://blockrun.ai"
|
||||
DefaultBlockRunModel = "gpt-5.4"
|
||||
BlockRunChatEndpoint = "/api/v1/chat/completions"
|
||||
BaseUSDCContract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
|
||||
BaseChainID int64 = 8453
|
||||
BaseNetwork = "eip155:8453"
|
||||
)
|
||||
|
||||
// EIP-712 type hashes for USDC TransferWithAuthorization (ERC-3009)
|
||||
var (
|
||||
eip712DomainTypeHash = keccak256String("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
|
||||
transferWithAuthTypeHash = keccak256String("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")
|
||||
)
|
||||
|
||||
func keccak256String(s string) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write([]byte(s))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func keccak256Bytes(data ...[]byte) []byte {
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
for _, b := range data {
|
||||
h.Write(b)
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// BlockRunBaseClient implements AIClient using BlockRun's API with x402 v2 EIP-712 payment signing.
|
||||
type BlockRunBaseClient struct {
|
||||
*Client
|
||||
privateKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewBlockRunBaseClient creates a BlockRun Base wallet client (backward compatible).
|
||||
func NewBlockRunBaseClient() AIClient {
|
||||
return NewBlockRunBaseClientWithOptions()
|
||||
}
|
||||
|
||||
// NewBlockRunBaseClientWithOptions creates a BlockRun Base wallet client.
|
||||
func NewBlockRunBaseClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderBlockRunBase),
|
||||
WithModel(DefaultBlockRunModel),
|
||||
WithBaseURL(DefaultBlockRunBaseURL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||
|
||||
c := &BlockRunBaseClient{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the EVM private key (hex, with or without 0x prefix).
|
||||
// customModel selects the AI model to use (e.g. "claude-sonnet-4.6"); empty means default.
|
||||
func (c *BlockRunBaseClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
hexKey := strings.TrimPrefix(apiKey, "0x")
|
||||
privKey, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] BlockRun Base: invalid private key: %v", err)
|
||||
} else {
|
||||
c.privateKey = privKey
|
||||
c.APIKey = apiKey
|
||||
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base wallet: %s", addr)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Base model: %s", DefaultBlockRunModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *BlockRunBaseClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signPayment, "BlockRun Base", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signPayment, "BlockRun Base", req)
|
||||
}
|
||||
|
||||
// signPayment parses the Payment-Required header (x402 v2) and returns a signed payment value.
|
||||
func (c *BlockRunBaseClient) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return signBasePaymentHeader(c.privateKey, paymentHeaderB64, "BlockRun Base")
|
||||
}
|
||||
|
||||
// signX402Payment is the shared EIP-712 signing logic for x402 v2 on Base USDC.
|
||||
// Used by both BlockRunBaseClient and Claw402Client.
|
||||
func signX402Payment(privateKey *ecdsa.PrivateKey, senderAddr string, opt x402AcceptOption, resource *x402Resource) (string, error) {
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
network := opt.Network
|
||||
asset := opt.Asset
|
||||
extra := opt.Extra
|
||||
maxTimeout := opt.MaxTimeoutSeconds
|
||||
if maxTimeout == 0 {
|
||||
maxTimeout = 300
|
||||
}
|
||||
|
||||
resourceURL := ""
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if resource != nil {
|
||||
resourceURL = resource.URL
|
||||
resourceDesc = resource.Description
|
||||
resourceMime = resource.MimeType
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
validAfter := int64(0)
|
||||
validBefore := now + int64(maxTimeout)
|
||||
|
||||
nonceBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
nonce := "0x" + hex.EncodeToString(nonceBytes)
|
||||
|
||||
domainName := "USD Coin"
|
||||
domainVersion := "2"
|
||||
if extra != nil {
|
||||
if v, ok := extra["name"]; ok && v != "" {
|
||||
domainName = v
|
||||
}
|
||||
if v, ok := extra["version"]; ok && v != "" {
|
||||
domainVersion = v
|
||||
}
|
||||
}
|
||||
|
||||
domainSeparator, err := buildDomainSeparatorDynamic(domainName, domainVersion, network, asset)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build domain separator: %w", err)
|
||||
}
|
||||
|
||||
amountBig, err := parseBigInt(amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid amount: %w", err)
|
||||
}
|
||||
|
||||
structHash, err := buildTransferWithAuthHashDynamic(senderAddr, recipient, amountBig, validAfter, validBefore, nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build struct hash: %w", err)
|
||||
}
|
||||
|
||||
digest := make([]byte, 0, 66)
|
||||
digest = append(digest, 0x19, 0x01)
|
||||
digest = append(digest, domainSeparator...)
|
||||
digest = append(digest, structHash...)
|
||||
hash := keccak256Bytes(digest)
|
||||
|
||||
sig, err := crypto.Sign(hash, privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign: %w", err)
|
||||
}
|
||||
if sig[64] < 27 {
|
||||
sig[64] += 27
|
||||
}
|
||||
|
||||
sigHex := "0x" + hex.EncodeToString(sig)
|
||||
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
"url": resourceURL,
|
||||
"description": resourceDesc,
|
||||
"mimeType": resourceMime,
|
||||
},
|
||||
"accepted": map[string]interface{}{
|
||||
"scheme": "exact",
|
||||
"network": network,
|
||||
"amount": amount,
|
||||
"asset": asset,
|
||||
"payTo": recipient,
|
||||
"maxTimeoutSeconds": maxTimeout,
|
||||
"extra": extra,
|
||||
},
|
||||
"payload": map[string]interface{}{
|
||||
"signature": sigHex,
|
||||
"authorization": map[string]string{
|
||||
"from": senderAddr,
|
||||
"to": recipient,
|
||||
"value": amount,
|
||||
"validAfter": fmt.Sprintf("%d", validAfter),
|
||||
"validBefore": fmt.Sprintf("%d", validBefore),
|
||||
"nonce": nonce,
|
||||
},
|
||||
},
|
||||
"extensions": map[string]interface{}{},
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(paymentData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payment result: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(resultJSON), nil
|
||||
}
|
||||
|
||||
// buildDomainSeparatorDynamic builds the EIP-712 domain separator using runtime values.
|
||||
func buildDomainSeparatorDynamic(name, version, network, asset string) ([]byte, error) {
|
||||
// Extract chain ID from network string like "eip155:8453"
|
||||
chainID := new(big.Int).SetInt64(BaseChainID)
|
||||
if strings.HasPrefix(network, "eip155:") {
|
||||
parts := strings.SplitN(network, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
if n, ok := new(big.Int).SetString(parts[1], 10); ok {
|
||||
chainID = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contractAddr, err := hex.DecodeString(strings.TrimPrefix(asset, "0x"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid contract address: %w", err)
|
||||
}
|
||||
|
||||
nameHash := keccak256String(name)
|
||||
versionHash := keccak256String(version)
|
||||
|
||||
encoded := make([]byte, 0, 5*32)
|
||||
encoded = append(encoded, leftPad32(eip712DomainTypeHash)...)
|
||||
encoded = append(encoded, leftPad32(nameHash)...)
|
||||
encoded = append(encoded, leftPad32(versionHash)...)
|
||||
encoded = append(encoded, leftPad32(chainID.Bytes())...)
|
||||
addrPadded := make([]byte, 32)
|
||||
copy(addrPadded[32-len(contractAddr):], contractAddr)
|
||||
encoded = append(encoded, addrPadded...)
|
||||
|
||||
return keccak256Bytes(encoded), nil
|
||||
}
|
||||
|
||||
// buildTransferWithAuthHashDynamic builds the struct hash for TransferWithAuthorization.
|
||||
func buildTransferWithAuthHashDynamic(from, to string, value *big.Int, validAfter, validBefore int64, nonce string) ([]byte, error) {
|
||||
fromBytes, err := hexToAddress(from)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid from address: %w", err)
|
||||
}
|
||||
toBytes, err := hexToAddress(to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid to address: %w", err)
|
||||
}
|
||||
nonceBytes, err := hexToBytes32(nonce)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid nonce: %w", err)
|
||||
}
|
||||
|
||||
validAfterBig := new(big.Int).SetInt64(validAfter)
|
||||
validBeforeBig := new(big.Int).SetInt64(validBefore)
|
||||
|
||||
encoded := make([]byte, 0, 7*32)
|
||||
encoded = append(encoded, leftPad32(transferWithAuthTypeHash)...)
|
||||
encoded = append(encoded, leftPad32(fromBytes)...)
|
||||
encoded = append(encoded, leftPad32(toBytes)...)
|
||||
encoded = append(encoded, leftPad32(value.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(validAfterBig.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(validBeforeBig.Bytes())...)
|
||||
encoded = append(encoded, leftPad32(nonceBytes)...)
|
||||
|
||||
return keccak256Bytes(encoded), nil
|
||||
}
|
||||
|
||||
func hexToAddress(s string) ([]byte, error) {
|
||||
s = strings.TrimPrefix(s, "0x")
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) != 20 {
|
||||
return nil, fmt.Errorf("address must be 20 bytes, got %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func hexToBytes32(s string) ([]byte, error) {
|
||||
s = strings.TrimPrefix(s, "0x")
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) > 32 {
|
||||
return nil, fmt.Errorf("nonce too long: %d bytes", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func parseBigInt(s string) (*big.Int, error) {
|
||||
n := new(big.Int)
|
||||
// Only treat as hex when explicitly prefixed with 0x/0X.
|
||||
// x402 amounts are always decimal strings (e.g. "3000" = 0.003 USDC).
|
||||
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
|
||||
if _, ok := n.SetString(s[2:], 16); ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse hex big.Int from %q", s)
|
||||
}
|
||||
if _, ok := n.SetString(s, 10); ok {
|
||||
return n, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse big.Int from %q", s)
|
||||
}
|
||||
|
||||
// leftPad32 pads a byte slice to 32 bytes on the left (ABI encoding).
|
||||
func leftPad32(b []byte) []byte {
|
||||
if len(b) >= 32 {
|
||||
return b[:32]
|
||||
}
|
||||
padded := make([]byte, 32)
|
||||
copy(padded[32-len(b):], b)
|
||||
return padded
|
||||
}
|
||||
|
||||
// buildUrl returns the full BlockRun endpoint URL.
|
||||
func (c *BlockRunBaseClient) buildUrl() string {
|
||||
return DefaultBlockRunBaseURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
func (c *BlockRunBaseClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
277
mcp/blockrun_sol.go
Normal file
277
mcp/blockrun_sol.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gagliardetto/solana-go"
|
||||
"github.com/gagliardetto/solana-go/programs/compute-budget"
|
||||
"github.com/gagliardetto/solana-go/programs/token"
|
||||
"github.com/gagliardetto/solana-go/rpc"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderBlockRunSol = "blockrun-sol"
|
||||
DefaultBlockRunSolURL = "https://sol.blockrun.ai"
|
||||
SolanaUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
||||
SolanaNetwork = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"
|
||||
SolanaMainnetRPC = "https://api.mainnet-beta.solana.com"
|
||||
|
||||
// Compute budget defaults (match @x402/svm)
|
||||
computeUnitLimit = uint32(8000)
|
||||
computeUnitPrice = uint64(1)
|
||||
)
|
||||
|
||||
// BlockRunSolClient implements AIClient using BlockRun's Solana x402 v2 payment protocol.
|
||||
type BlockRunSolClient struct {
|
||||
*Client
|
||||
keypair solana.PrivateKey
|
||||
}
|
||||
|
||||
// NewBlockRunSolClient creates a BlockRun Solana wallet client (backward compatible).
|
||||
func NewBlockRunSolClient() AIClient {
|
||||
return NewBlockRunSolClientWithOptions()
|
||||
}
|
||||
|
||||
// NewBlockRunSolClientWithOptions creates a BlockRun Solana wallet client.
|
||||
func NewBlockRunSolClientWithOptions(opts ...ClientOption) AIClient {
|
||||
baseOpts := []ClientOption{
|
||||
WithProvider(ProviderBlockRunSol),
|
||||
WithModel(DefaultBlockRunModel),
|
||||
WithBaseURL(DefaultBlockRunSolURL),
|
||||
}
|
||||
allOpts := append(baseOpts, opts...)
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
baseClient.UseFullURL = true
|
||||
baseClient.BaseURL = DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
|
||||
c := &BlockRunSolClient{Client: baseClient}
|
||||
baseClient.hooks = c
|
||||
return c
|
||||
}
|
||||
|
||||
// SetAPIKey stores the Solana wallet private key (base58-encoded 64-byte keypair).
|
||||
// customModel selects the AI model; empty means default.
|
||||
func (c *BlockRunSolClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
kp, err := solana.PrivateKeyFromBase58(strings.TrimSpace(apiKey))
|
||||
if err != nil {
|
||||
c.logger.Warnf("⚠️ [MCP] BlockRun Sol: failed to parse private key: %v", err)
|
||||
return
|
||||
}
|
||||
c.keypair = kp
|
||||
c.APIKey = apiKey
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol wallet: %s", kp.PublicKey().String())
|
||||
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] BlockRun Sol model: %s", DefaultBlockRunModel)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) setAuthHeader(h http.Header) { x402SetAuthHeader(h) }
|
||||
|
||||
func (c *BlockRunSolClient) call(systemPrompt, userPrompt string) (string, error) {
|
||||
return x402Call(c.Client, c.signSolanaPayment, "BlockRun Sol", systemPrompt, userPrompt)
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
return x402CallFull(c.Client, c.signSolanaPayment, "BlockRun Sol", req)
|
||||
}
|
||||
|
||||
// signSolanaPayment parses the Payment-Required header and builds a signed x402 v2 Solana payload.
|
||||
func (c *BlockRunSolClient) signSolanaPayment(paymentHeaderB64 string) (string, error) {
|
||||
if c.keypair == nil {
|
||||
return "", fmt.Errorf("no private key set for BlockRun Sol wallet")
|
||||
}
|
||||
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 Solana header: %w", err)
|
||||
}
|
||||
|
||||
// Find the Solana option
|
||||
var opt *x402AcceptOption
|
||||
for i := range req.Accepts {
|
||||
if strings.HasPrefix(req.Accepts[i].Network, "solana:") {
|
||||
opt = &req.Accepts[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if opt == nil {
|
||||
return "", fmt.Errorf("no Solana payment option in x402 response")
|
||||
}
|
||||
|
||||
recipient := opt.PayTo
|
||||
amount := opt.Amount
|
||||
feePayer := ""
|
||||
if opt.Extra != nil {
|
||||
feePayer = opt.Extra["feePayer"]
|
||||
}
|
||||
if feePayer == "" {
|
||||
return "", fmt.Errorf("feePayer missing from Solana x402 extra")
|
||||
}
|
||||
|
||||
maxTimeout := opt.MaxTimeoutSeconds
|
||||
if maxTimeout == 0 {
|
||||
maxTimeout = 300
|
||||
}
|
||||
|
||||
resourceURL := DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
resourceDesc := ""
|
||||
resourceMime := "application/json"
|
||||
if req.Resource != nil {
|
||||
resourceURL = req.Resource.URL
|
||||
resourceDesc = req.Resource.Description
|
||||
resourceMime = req.Resource.MimeType
|
||||
}
|
||||
|
||||
// Build the SPL TransferChecked transaction
|
||||
txB64, err := c.buildSolanaTransferTx(recipient, feePayer, amount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build Solana transfer tx: %w", err)
|
||||
}
|
||||
|
||||
// Build x402 v2 payment payload
|
||||
paymentData := map[string]interface{}{
|
||||
"x402Version": 2,
|
||||
"resource": map[string]string{
|
||||
"url": resourceURL,
|
||||
"description": resourceDesc,
|
||||
"mimeType": resourceMime,
|
||||
},
|
||||
"accepted": map[string]interface{}{
|
||||
"scheme": "exact",
|
||||
"network": SolanaNetwork,
|
||||
"amount": amount,
|
||||
"asset": SolanaUSDCMint,
|
||||
"payTo": recipient,
|
||||
"maxTimeoutSeconds": maxTimeout,
|
||||
"extra": opt.Extra,
|
||||
},
|
||||
"payload": map[string]string{
|
||||
"transaction": txB64,
|
||||
},
|
||||
"extensions": map[string]interface{}{},
|
||||
}
|
||||
|
||||
resultJSON, err := json.Marshal(paymentData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal Solana payment: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(resultJSON), nil
|
||||
}
|
||||
|
||||
// buildSolanaTransferTx builds a partial-signed VersionedTransaction for SPL USDC TransferChecked.
|
||||
// The fee payer (CDP facilitator) slot is left with a zero signature; only the user signs.
|
||||
func (c *BlockRunSolClient) buildSolanaTransferTx(recipient, feePayer, amountStr string) (string, error) {
|
||||
ownerPubkey := c.keypair.PublicKey()
|
||||
|
||||
// Parse recipient and feePayer
|
||||
recipientPK, err := solana.PublicKeyFromBase58(recipient)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid recipient address: %w", err)
|
||||
}
|
||||
feePayerPK, err := solana.PublicKeyFromBase58(feePayer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid feePayer address: %w", err)
|
||||
}
|
||||
mintPK := solana.MustPublicKeyFromBase58(SolanaUSDCMint)
|
||||
|
||||
// Parse amount
|
||||
var amountU64 uint64
|
||||
if _, err := fmt.Sscanf(amountStr, "%d", &amountU64); err != nil {
|
||||
return "", fmt.Errorf("invalid amount %q: %w", amountStr, err)
|
||||
}
|
||||
|
||||
// Derive ATAs
|
||||
sourceATA, _, err := solana.FindAssociatedTokenAddress(ownerPubkey, mintPK)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive source ATA: %w", err)
|
||||
}
|
||||
destATA, _, err := solana.FindAssociatedTokenAddress(recipientPK, mintPK)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to derive dest ATA: %w", err)
|
||||
}
|
||||
|
||||
// Fetch latest blockhash from Solana mainnet
|
||||
rpcClient := rpc.New(SolanaMainnetRPC)
|
||||
bhResp, err := rpcClient.GetLatestBlockhash(context.Background(), rpc.CommitmentFinalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch blockhash: %w", err)
|
||||
}
|
||||
recentBlockhash := bhResp.Value.Blockhash
|
||||
|
||||
// Build instructions: ComputeBudgetSetLimit, ComputeBudgetSetPrice, TransferChecked
|
||||
setLimitIx, err := computebudget.NewSetComputeUnitLimitInstruction(computeUnitLimit).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build SetComputeUnitLimit: %w", err)
|
||||
}
|
||||
setPriceIx, err := computebudget.NewSetComputeUnitPriceInstruction(computeUnitPrice).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build SetComputeUnitPrice: %w", err)
|
||||
}
|
||||
transferIx, err := token.NewTransferCheckedInstruction(
|
||||
amountU64,
|
||||
6, // USDC decimals
|
||||
sourceATA,
|
||||
mintPK,
|
||||
destATA,
|
||||
ownerPubkey,
|
||||
[]solana.PublicKey{},
|
||||
).ValidateAndBuild()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build TransferChecked: %w", err)
|
||||
}
|
||||
|
||||
// Build transaction with feePayer as payer (matches Python SDK)
|
||||
tx, err := solana.NewTransaction(
|
||||
[]solana.Instruction{setLimitIx, setPriceIx, transferIx},
|
||||
recentBlockhash,
|
||||
solana.TransactionPayer(feePayerPK),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build transaction: %w", err)
|
||||
}
|
||||
|
||||
// Partial sign: user signs; fee_payer (CDP) co-signs on server side
|
||||
// The transaction has 2 signers: [feePayer (index 0), owner (index 1)]
|
||||
// We sign only our index (owner).
|
||||
_, err = tx.Sign(func(key solana.PublicKey) *solana.PrivateKey {
|
||||
if key.Equals(ownerPubkey) {
|
||||
return &c.keypair
|
||||
}
|
||||
return nil // feePayer will be signed by BlockRun CDP
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign transaction: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := tx.MarshalBinary()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(txBytes), nil
|
||||
}
|
||||
|
||||
// buildUrl returns the full BlockRun Solana endpoint URL.
|
||||
func (c *BlockRunSolClient) buildUrl() string {
|
||||
return DefaultBlockRunSolURL + BlockRunChatEndpoint
|
||||
}
|
||||
|
||||
func (c *BlockRunSolClient) buildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
return x402BuildRequest(url, jsonData)
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
// Package mcp — ClaudeClient implements the Anthropic Messages API.
|
||||
//
|
||||
// Wire-format differences from the OpenAI-compatible base Client:
|
||||
//
|
||||
// ┌─────────────────────┬───────────────────────────┬─────────────────────────────────┐
|
||||
// │ Concept │ OpenAI format │ Anthropic format │
|
||||
// ├─────────────────────┼───────────────────────────┼─────────────────────────────────┤
|
||||
// │ Endpoint │ /v1/chat/completions │ /v1/messages │
|
||||
// │ Auth header │ Authorization: Bearer xxx │ x-api-key: xxx │
|
||||
// │ System prompt │ messages[0] role=system │ top-level "system" field │
|
||||
// │ Tool definition │ type=function + parameters │ name + description + input_schema│
|
||||
// │ Tool choice │ "auto" (string) │ {"type":"auto"} (object) │
|
||||
// │ Assistant tool call │ tool_calls array │ content[{type:tool_use,...}] │
|
||||
// │ Tool result │ role=tool + tool_call_id │ role=user content[tool_result] │
|
||||
// │ Max tokens │ max_tokens │ max_tokens (same) │
|
||||
// └─────────────────────┴───────────────────────────┴─────────────────────────────────┘
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -9,78 +25,67 @@ import (
|
||||
const (
|
||||
ProviderClaude = "claude"
|
||||
DefaultClaudeBaseURL = "https://api.anthropic.com/v1"
|
||||
DefaultClaudeModel = "claude-opus-4-5-20251101"
|
||||
DefaultClaudeModel = "claude-opus-4-6"
|
||||
)
|
||||
|
||||
// ClaudeClient wraps the base Client and overrides the methods that differ
|
||||
// for the Anthropic Messages API. All other behaviour (retry, timeout,
|
||||
// logging) is inherited unchanged.
|
||||
type ClaudeClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewClaudeClient creates Claude client (backward compatible)
|
||||
// NewClaudeClient creates a ClaudeClient with default settings.
|
||||
func NewClaudeClient() AIClient {
|
||||
return NewClaudeClientWithOptions()
|
||||
}
|
||||
|
||||
// NewClaudeClientWithOptions creates Claude client (supports options pattern)
|
||||
// NewClaudeClientWithOptions creates a ClaudeClient with optional overrides.
|
||||
func NewClaudeClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create Claude preset options
|
||||
claudeOpts := []ClientOption{
|
||||
baseClient := NewClient(append([]ClientOption{
|
||||
WithProvider(ProviderClaude),
|
||||
WithModel(DefaultClaudeModel),
|
||||
WithBaseURL(DefaultClaudeBaseURL),
|
||||
}
|
||||
}, opts...)...).(*Client)
|
||||
|
||||
// 2. Merge user options (user options have higher priority)
|
||||
allOpts := append(claudeOpts, opts...)
|
||||
|
||||
// 3. Create base client
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
|
||||
// 4. Create Claude client
|
||||
claudeClient := &ClaudeClient{
|
||||
Client: baseClient,
|
||||
}
|
||||
|
||||
// 5. Set hooks to point to ClaudeClient (implement dynamic dispatch)
|
||||
baseClient.hooks = claudeClient
|
||||
|
||||
return claudeClient
|
||||
c := &ClaudeClient{Client: baseClient}
|
||||
baseClient.hooks = c // wire dynamic dispatch to ClaudeClient
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
// ── Hook overrides ────────────────────────────────────────────────────────────
|
||||
|
||||
// SetAPIKey stores credentials and optional custom endpoint / model.
|
||||
func (c *ClaudeClient) SetAPIKey(apiKey, customURL, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] Claude API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default BaseURL: %s", c.BaseURL)
|
||||
c.logger.Infof("🔧 [MCP] Claude BaseURL: %s", customURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] Claude using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] Claude using default Model: %s", c.Model)
|
||||
c.logger.Infof("🔧 [MCP] Claude Model: %s", customModel)
|
||||
}
|
||||
}
|
||||
|
||||
// setAuthHeader Claude uses x-api-key header instead of Authorization Bearer
|
||||
func (c *ClaudeClient) setAuthHeader(reqHeaders http.Header) {
|
||||
reqHeaders.Set("x-api-key", c.APIKey)
|
||||
reqHeaders.Set("anthropic-version", "2023-06-01")
|
||||
// setAuthHeader uses x-api-key instead of Authorization: Bearer.
|
||||
func (c *ClaudeClient) setAuthHeader(h http.Header) {
|
||||
h.Set("x-api-key", c.APIKey)
|
||||
h.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// buildUrl Claude uses /messages endpoint
|
||||
// buildUrl targets /messages instead of /chat/completions.
|
||||
func (c *ClaudeClient) buildUrl() string {
|
||||
return fmt.Sprintf("%s/messages", c.BaseURL)
|
||||
}
|
||||
|
||||
// buildMCPRequestBody Claude has different request format
|
||||
// buildMCPRequestBody builds the Anthropic wire format for the simple
|
||||
// CallWithMessages path (no tool support).
|
||||
func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
requestBody := map[string]any{
|
||||
return map[string]any{
|
||||
"model": c.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
@@ -88,16 +93,175 @@ func (c *ClaudeClient) buildMCPRequestBody(systemPrompt, userPrompt string) map[
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
}
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// parseMCPResponse Claude has different response format
|
||||
// buildRequestBodyFromRequest converts a *Request into the Anthropic Messages
|
||||
// API wire format. This is the key override that makes tool calling work
|
||||
// correctly with Claude.
|
||||
//
|
||||
// Conversions applied:
|
||||
//
|
||||
// - System messages are lifted to the top-level "system" field.
|
||||
// - Tool definitions: parameters → input_schema, wrapper removed.
|
||||
// - Assistant messages with ToolCalls → content[{type:tool_use,...}].
|
||||
// - Tool result messages (role=tool) → role=user with tool_result blocks.
|
||||
// Consecutive tool results are merged into a single user turn (Anthropic
|
||||
// requires strictly alternating user/assistant turns).
|
||||
// - tool_choice "auto"/"any" → {"type":"auto"/"any"} object.
|
||||
func (c *ClaudeClient) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// ── 1. Separate system prompt from conversation messages ──────────────────
|
||||
var systemPrompt string
|
||||
var convMsgs []Message
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == "system" {
|
||||
systemPrompt = m.Content
|
||||
} else {
|
||||
convMsgs = append(convMsgs, m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Convert messages to Anthropic format ───────────────────────────────
|
||||
anthropicMsgs := convertMessagesToAnthropic(convMsgs)
|
||||
|
||||
// ── 3. Convert tool definitions (parameters → input_schema) ──────────────
|
||||
var anthropicTools []map[string]any
|
||||
for _, t := range req.Tools {
|
||||
anthropicTools = append(anthropicTools, map[string]any{
|
||||
"name": t.Function.Name,
|
||||
"description": t.Function.Description,
|
||||
"input_schema": t.Function.Parameters,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 4. Assemble request body ──────────────────────────────────────────────
|
||||
body := map[string]any{
|
||||
"model": req.Model,
|
||||
"max_tokens": c.MaxTokens,
|
||||
"system": systemPrompt,
|
||||
"messages": anthropicMsgs,
|
||||
}
|
||||
|
||||
if len(anthropicTools) > 0 {
|
||||
body["tools"] = anthropicTools
|
||||
}
|
||||
|
||||
// tool_choice: Anthropic uses an object, not a string.
|
||||
switch req.ToolChoice {
|
||||
case "auto":
|
||||
body["tool_choice"] = map[string]any{"type": "auto"}
|
||||
case "any":
|
||||
body["tool_choice"] = map[string]any{"type": "any"}
|
||||
case "none", "":
|
||||
// omit — no tool_choice sent
|
||||
}
|
||||
|
||||
if req.Temperature != nil {
|
||||
body["temperature"] = *req.Temperature
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// convertMessagesToAnthropic translates from the OpenAI-shaped mcp.Message
|
||||
// slice to Anthropic's messages array.
|
||||
//
|
||||
// Rules:
|
||||
// 1. role=assistant + ToolCalls → role=assistant, content=[tool_use, ...]
|
||||
// 2. role=tool (result) → role=user, content=[tool_result, ...]
|
||||
// Consecutive tool-result messages are merged into one user turn so the
|
||||
// conversation always alternates user/assistant.
|
||||
// 3. All other messages → {role, content} as-is.
|
||||
func convertMessagesToAnthropic(msgs []Message) []map[string]any {
|
||||
var out []map[string]any
|
||||
|
||||
for i := 0; i < len(msgs); {
|
||||
msg := msgs[i]
|
||||
|
||||
switch {
|
||||
// ── Assistant message carrying tool calls ─────────────────────────────
|
||||
case msg.Role == "assistant" && len(msg.ToolCalls) > 0:
|
||||
var blocks []map[string]any
|
||||
for _, tc := range msg.ToolCalls {
|
||||
// Arguments are a JSON string; Claude wants a parsed object.
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||
input = map[string]any{"_raw": tc.Function.Arguments}
|
||||
}
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_use",
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"input": input,
|
||||
})
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "assistant",
|
||||
"content": blocks,
|
||||
})
|
||||
i++
|
||||
|
||||
// ── Tool result message(s) → single user turn ─────────────────────────
|
||||
case msg.Role == "tool":
|
||||
// Collect all consecutive tool-result messages.
|
||||
var blocks []map[string]any
|
||||
for i < len(msgs) && msgs[i].Role == "tool" {
|
||||
blocks = append(blocks, map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msgs[i].ToolCallID,
|
||||
"content": msgs[i].Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"role": "user",
|
||||
"content": blocks,
|
||||
})
|
||||
|
||||
// ── Regular user / assistant text message ─────────────────────────────
|
||||
default:
|
||||
out = append(out, map[string]any{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Response parsers ──────────────────────────────────────────────────────────
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from an Anthropic response.
|
||||
// Used by CallWithMessages / CallWithRequest (no tool support).
|
||||
func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
var response struct {
|
||||
r, err := c.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls from an Anthropic
|
||||
// response envelope.
|
||||
//
|
||||
// Anthropic response shape:
|
||||
//
|
||||
// {
|
||||
// "content": [
|
||||
// {"type": "text", "text": "..."},
|
||||
// {"type": "tool_use", "id": "...", "name": "...", "input": {...}}
|
||||
// ],
|
||||
// "stop_reason": "tool_use" | "end_turn"
|
||||
// }
|
||||
func (c *ClaudeClient) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var raw struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
} `json:"content"`
|
||||
Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
@@ -109,36 +273,46 @@ func (c *ClaudeClient) parseMCPResponse(body []byte) (string, error) {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Claude response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Anthropic response: %w — body: %s", err, body)
|
||||
}
|
||||
if raw.Error != nil {
|
||||
return nil, fmt.Errorf("Anthropic API error: %s — %s", raw.Error.Type, raw.Error.Message)
|
||||
}
|
||||
|
||||
if response.Error != nil {
|
||||
return "", fmt.Errorf("Claude API error: %s - %s", response.Error.Type, response.Error.Message)
|
||||
}
|
||||
|
||||
if len(response.Content) == 0 {
|
||||
return "", fmt.Errorf("Claude returned empty content, body: %s", string(body))
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
totalTokens := response.Usage.InputTokens + response.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && totalTokens > 0 {
|
||||
total := raw.Usage.InputTokens + raw.Usage.OutputTokens
|
||||
if TokenUsageCallback != nil && total > 0 {
|
||||
TokenUsageCallback(TokenUsage{
|
||||
Provider: c.Provider,
|
||||
Model: c.Model,
|
||||
PromptTokens: response.Usage.InputTokens,
|
||||
CompletionTokens: response.Usage.OutputTokens,
|
||||
TotalTokens: totalTokens,
|
||||
PromptTokens: raw.Usage.InputTokens,
|
||||
CompletionTokens: raw.Usage.OutputTokens,
|
||||
TotalTokens: total,
|
||||
})
|
||||
}
|
||||
|
||||
// Find text content
|
||||
for _, content := range response.Content {
|
||||
if content.Type == "text" {
|
||||
return content.Text, nil
|
||||
result := &LLMResponse{}
|
||||
for _, block := range raw.Content {
|
||||
switch block.Type {
|
||||
case "text":
|
||||
result.Content = block.Text
|
||||
|
||||
case "tool_use":
|
||||
// Input is a JSON object; serialise back to a JSON string so it
|
||||
// matches the ToolCallFunction.Arguments field (always a string).
|
||||
argsJSON, err := json.Marshal(block.Input)
|
||||
if err != nil {
|
||||
argsJSON = []byte("{}")
|
||||
}
|
||||
result.ToolCalls = append(result.ToolCalls, ToolCall{
|
||||
ID: block.ID,
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: block.Name,
|
||||
Arguments: string(argsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no text content in Claude response")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
248
mcp/claude_client_test.go
Normal file
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -232,10 +234,21 @@ func (client *Client) marshalRequestBody(requestBody map[string]any) ([]byte, er
|
||||
}
|
||||
|
||||
func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
r, err := client.parseMCPResponseFull(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// parseMCPResponseFull parses the OpenAI-format response body and returns both
|
||||
// the text content and any tool calls.
|
||||
func (client *Client) parseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
@@ -246,11 +259,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Choices) == 0 {
|
||||
return "", fmt.Errorf("API returned empty response")
|
||||
return nil, fmt.Errorf("API returned empty response")
|
||||
}
|
||||
|
||||
// Report token usage if callback is set
|
||||
@@ -264,7 +277,11 @@ func (client *Client) parseMCPResponse(body []byte) (string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return result.Choices[0].Message.Content, nil
|
||||
msg := result.Choices[0].Message
|
||||
return &LLMResponse{
|
||||
Content: msg.Content,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *Client) buildUrl() string {
|
||||
@@ -425,50 +442,106 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
|
||||
return "", fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// CallWithRequestFull calls the AI API and returns both text content and tool calls.
|
||||
func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
if client.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
maxRetries := client.config.MaxRetries
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
client.logger.Warnf("⚠️ AI API call failed, retrying (%d/%d)...", attempt, maxRetries)
|
||||
}
|
||||
result, err := client.callWithRequestFull(req)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !client.hooks.isRetryableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.config.RetryWaitBase * time.Duration(attempt)
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// callWithRequestFull single call that returns LLMResponse (content + tool calls).
|
||||
func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
client.logger.Infof("📡 [%s] Request AI Server (full): BaseURL: %s", client.String(), client.BaseURL)
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return client.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
|
||||
// callWithRequest single AI API call (using Request object)
|
||||
func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
// Print current AI configuration
|
||||
client.logger.Infof("📡 [%s] Request AI Server with Builder: BaseURL: %s", client.String(), client.BaseURL)
|
||||
client.logger.Debugf("[%s] Messages count: %d", client.String(), len(req.Messages))
|
||||
|
||||
// Build request body (from Request object)
|
||||
requestBody := client.buildRequestBodyFromRequest(req)
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
|
||||
// Serialize request body
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Build URL
|
||||
url := client.hooks.buildUrl()
|
||||
client.logger.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Send HTTP request
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
result, err := client.hooks.parseMCPResponse(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fail to parse AI server response: %w", err)
|
||||
@@ -479,13 +552,23 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
|
||||
// buildRequestBodyFromRequest builds request body from Request object
|
||||
func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
// Convert Message to API format
|
||||
messages := make([]map[string]string, 0, len(req.Messages))
|
||||
// Convert Message to API format — must use map[string]any to support
|
||||
// tool-call messages (tool_calls, tool_call_id fields).
|
||||
messages := make([]map[string]any, 0, len(req.Messages))
|
||||
for _, msg := range req.Messages {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
m := map[string]any{"role": msg.Role}
|
||||
if len(msg.ToolCalls) > 0 {
|
||||
// Assistant message that contains tool invocations.
|
||||
// content must be null/omitted for OpenAI compatibility.
|
||||
m["tool_calls"] = msg.ToolCalls
|
||||
} else if msg.ToolCallID != "" {
|
||||
// Tool result message (role="tool").
|
||||
m["tool_call_id"] = msg.ToolCallID
|
||||
m["content"] = msg.Content
|
||||
} else {
|
||||
m["content"] = msg.Content
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Build basic request body
|
||||
@@ -544,3 +627,124 @@ func (client *Client) buildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
|
||||
return requestBody
|
||||
}
|
||||
|
||||
// CallWithRequestStream streams the LLM response via SSE (Server-Sent Events).
|
||||
// onChunk is called with the full accumulated text so far after each received chunk.
|
||||
// Returns the complete final text when the stream ends.
|
||||
//
|
||||
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
|
||||
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
|
||||
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
|
||||
if client.APIKey == "" {
|
||||
return "", fmt.Errorf("AI API key not set")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = client.Model
|
||||
}
|
||||
req.Stream = true
|
||||
|
||||
requestBody := client.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := client.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url := client.hooks.buildUrl()
|
||||
httpReq, err := client.hooks.buildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 30 seconds.
|
||||
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
|
||||
const idleTimeout = 60 * time.Second
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetCh := make(chan struct{}, 1)
|
||||
go func() {
|
||||
t := time.NewTimer(idleTimeout)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cancel() // idle timeout: kill the connection
|
||||
return
|
||||
case <-resetCh:
|
||||
// received a line — reset the idle timer
|
||||
if !t.Stop() {
|
||||
select {
|
||||
case <-t.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
t.Reset(idleTimeout)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
httpReq = httpReq.WithContext(ctx)
|
||||
resp, err := client.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("streaming request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var accumulated strings.Builder
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
|
||||
for scanner.Scan() {
|
||||
// Ping the watchdog: we received a line, reset the idle timer.
|
||||
select {
|
||||
case resetCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
if data == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
// Parse the SSE JSON chunk
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
continue // skip malformed chunks
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
delta := chunk.Choices[0].Delta.Content
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
accumulated.WriteString(delta)
|
||||
if onChunk != nil {
|
||||
onChunk(accumulated.String())
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
|
||||
}
|
||||
|
||||
return accumulated.String(), nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
)
|
||||
|
||||
// Config client configuration (centralized management of all configurations)
|
||||
@@ -48,7 +49,7 @@ func DefaultConfig() *Config {
|
||||
|
||||
// Default dependencies (use global logger)
|
||||
Logger: logger.NewMCPLogger(),
|
||||
HTTPClient: &http.Client{Timeout: DefaultTimeout},
|
||||
HTTPClient: security.SafeHTTPClient(DefaultTimeout),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,52 @@ type AIClient interface {
|
||||
SetAPIKey(apiKey string, customURL string, customModel string)
|
||||
SetTimeout(timeout time.Duration)
|
||||
CallWithMessages(systemPrompt, userPrompt string) (string, error)
|
||||
CallWithRequest(req *Request) (string, error) // Builder pattern API (supports advanced features)
|
||||
CallWithRequest(req *Request) (string, error)
|
||||
// CallWithRequestStream streams the LLM response via SSE.
|
||||
// onChunk is called with the full accumulated text so far (not raw deltas).
|
||||
// Returns the complete final text when done.
|
||||
CallWithRequestStream(req *Request, onChunk func(string)) (string, error)
|
||||
// CallWithRequestFull returns both text content and tool calls.
|
||||
// Use this when the request includes Tools — the LLM may respond with
|
||||
// either a plain text reply (LLMResponse.Content) or tool invocations
|
||||
// (LLMResponse.ToolCalls), but not both.
|
||||
CallWithRequestFull(req *Request) (*LLMResponse, error)
|
||||
}
|
||||
|
||||
// clientHooks internal hook interface (for subclass to override specific steps)
|
||||
// These methods are only used inside the package to implement dynamic dispatch
|
||||
// clientHooks is the internal dispatch interface used to implement per-provider
|
||||
// polymorphism without Go's lack of virtual methods.
|
||||
//
|
||||
// Each method can be overridden by an embedding struct (e.g. ClaudeClient).
|
||||
// The base *Client provides OpenAI-compatible defaults; providers with a
|
||||
// different wire format (Anthropic, Gemini native, etc.) override only what
|
||||
// differs. All call-path methods in client.go invoke these via c.hooks so
|
||||
// that the override is always picked up at runtime.
|
||||
type clientHooks interface {
|
||||
// Hook methods that can be overridden by subclass
|
||||
|
||||
// ── Simple CallWithMessages path ────────────────────────────────────────
|
||||
call(systemPrompt, userPrompt string) (string, error)
|
||||
|
||||
buildMCPRequestBody(systemPrompt, userPrompt string) map[string]any
|
||||
|
||||
// ── Shared request plumbing ─────────────────────────────────────────────
|
||||
buildUrl() string
|
||||
buildRequest(url string, jsonData []byte) (*http.Request, error)
|
||||
setAuthHeader(reqHeaders http.Header)
|
||||
marshalRequestBody(requestBody map[string]any) ([]byte, error)
|
||||
|
||||
// ── Advanced (Request-object) path ──────────────────────────────────────
|
||||
// buildRequestBodyFromRequest converts a *Request into the provider's
|
||||
// native wire-format map. Providers that use a different protocol (e.g.
|
||||
// Anthropic uses "input_schema" for tools, "tool_use" content blocks, and
|
||||
// a top-level "system" field) override this method.
|
||||
buildRequestBodyFromRequest(req *Request) map[string]any
|
||||
|
||||
// parseMCPResponse extracts the plain-text reply from a non-streaming
|
||||
// response body.
|
||||
parseMCPResponse(body []byte) (string, error)
|
||||
|
||||
// parseMCPResponseFull extracts both text and tool calls. Providers whose
|
||||
// response envelope differs from the OpenAI choices[] structure (e.g.
|
||||
// Anthropic content[] with tool_use blocks) override this method.
|
||||
parseMCPResponseFull(body []byte) (*LLMResponse, error)
|
||||
|
||||
isRetryableError(err error) bool
|
||||
}
|
||||
|
||||
83
mcp/minimax_client.go
Normal file
83
mcp/minimax_client.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
ProviderMiniMax = "minimax"
|
||||
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
|
||||
DefaultMiniMaxModel = "MiniMax-M2.5"
|
||||
)
|
||||
|
||||
type MiniMaxClient struct {
|
||||
*Client
|
||||
}
|
||||
|
||||
// NewMiniMaxClient creates MiniMax client (backward compatible)
|
||||
func NewMiniMaxClient() AIClient {
|
||||
return NewMiniMaxClientWithOptions()
|
||||
}
|
||||
|
||||
// NewMiniMaxClientWithOptions creates MiniMax client (supports options pattern)
|
||||
//
|
||||
// Usage examples:
|
||||
//
|
||||
// // Basic usage
|
||||
// client := mcp.NewMiniMaxClientWithOptions()
|
||||
//
|
||||
// // Custom configuration
|
||||
// client := mcp.NewMiniMaxClientWithOptions(
|
||||
// mcp.WithAPIKey("sk-xxx"),
|
||||
// mcp.WithLogger(customLogger),
|
||||
// mcp.WithTimeout(60*time.Second),
|
||||
// )
|
||||
func NewMiniMaxClientWithOptions(opts ...ClientOption) AIClient {
|
||||
// 1. Create MiniMax preset options
|
||||
minimaxOpts := []ClientOption{
|
||||
WithProvider(ProviderMiniMax),
|
||||
WithModel(DefaultMiniMaxModel),
|
||||
WithBaseURL(DefaultMiniMaxBaseURL),
|
||||
}
|
||||
|
||||
// 2. Merge user options (user options have higher priority)
|
||||
allOpts := append(minimaxOpts, opts...)
|
||||
|
||||
// 3. Create base client
|
||||
baseClient := NewClient(allOpts...).(*Client)
|
||||
|
||||
// 4. Create MiniMax client
|
||||
minimaxClient := &MiniMaxClient{
|
||||
Client: baseClient,
|
||||
}
|
||||
|
||||
// 5. Set hooks to point to MiniMaxClient (implement dynamic dispatch)
|
||||
baseClient.hooks = minimaxClient
|
||||
|
||||
return minimaxClient
|
||||
}
|
||||
|
||||
func (c *MiniMaxClient) SetAPIKey(apiKey string, customURL string, customModel string) {
|
||||
c.APIKey = apiKey
|
||||
|
||||
if len(apiKey) > 8 {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax API Key: %s...%s", apiKey[:4], apiKey[len(apiKey)-4:])
|
||||
}
|
||||
if customURL != "" {
|
||||
c.BaseURL = customURL
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom BaseURL: %s", customURL)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default BaseURL: %s", c.BaseURL)
|
||||
}
|
||||
if customModel != "" {
|
||||
c.Model = customModel
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using custom Model: %s", customModel)
|
||||
} else {
|
||||
c.logger.Infof("🔧 [MCP] MiniMax using default Model: %s", c.Model)
|
||||
}
|
||||
}
|
||||
|
||||
// MiniMax uses standard OpenAI-compatible API with Bearer auth
|
||||
func (c *MiniMaxClient) setAuthHeader(reqHeaders http.Header) {
|
||||
c.Client.setAuthHeader(reqHeaders)
|
||||
}
|
||||
272
mcp/minimax_client_test.go
Normal file
272
mcp/minimax_client_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Test MiniMaxClient Creation and Configuration
|
||||
// ============================================================
|
||||
|
||||
func TestNewMiniMaxClient_Default(t *testing.T) {
|
||||
client := NewMiniMaxClient()
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("client should not be nil")
|
||||
}
|
||||
|
||||
// Type assertion check
|
||||
mmClient, ok := client.(*MiniMaxClient)
|
||||
if !ok {
|
||||
t.Fatal("client should be *MiniMaxClient")
|
||||
}
|
||||
|
||||
// Verify default values
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should be '%s', got '%s'", ProviderMiniMax, mmClient.Provider)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", DefaultMiniMaxBaseURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", DefaultMiniMaxModel, mmClient.Model)
|
||||
}
|
||||
|
||||
if mmClient.logger == nil {
|
||||
t.Error("logger should not be nil")
|
||||
}
|
||||
|
||||
if mmClient.httpClient == nil {
|
||||
t.Error("httpClient should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMiniMaxClientWithOptions(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
customAPIKey := "sk-custom-key"
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
WithModel(customModel),
|
||||
WithAPIKey(customAPIKey),
|
||||
WithMaxTokens(4000),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify custom options are applied
|
||||
if mmClient.logger != mockLogger {
|
||||
t.Error("logger should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Error("Model should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.APIKey != customAPIKey {
|
||||
t.Error("APIKey should be set from option")
|
||||
}
|
||||
|
||||
if mmClient.MaxTokens != 4000 {
|
||||
t.Error("MaxTokens should be 4000")
|
||||
}
|
||||
|
||||
// Verify MiniMax default values are retained
|
||||
if mmClient.Provider != ProviderMiniMax {
|
||||
t.Errorf("Provider should still be '%s'", ProviderMiniMax)
|
||||
}
|
||||
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Errorf("BaseURL should still be '%s'", DefaultMiniMaxBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test SetAPIKey
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Test setting API Key (default URL and Model)
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", "")
|
||||
|
||||
if mmClient.APIKey != "sk-test-key-12345678" {
|
||||
t.Errorf("APIKey should be 'sk-test-key-12345678', got '%s'", mmClient.APIKey)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
if len(logs) == 0 {
|
||||
t.Error("should have logged API key setting")
|
||||
}
|
||||
|
||||
// Verify BaseURL and Model remain default
|
||||
if mmClient.BaseURL != DefaultMiniMaxBaseURL {
|
||||
t.Error("BaseURL should remain default")
|
||||
}
|
||||
|
||||
if mmClient.Model != DefaultMiniMaxModel {
|
||||
t.Error("Model should remain default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomURL(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customURL := "https://api.minimaxi.com/v1"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", customURL, "")
|
||||
|
||||
if mmClient.BaseURL != customURL {
|
||||
t.Errorf("BaseURL should be '%s', got '%s'", customURL, mmClient.BaseURL)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomURLLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom BaseURL: %s" {
|
||||
hasCustomURLLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomURLLog {
|
||||
t.Error("should have logged custom BaseURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_SetAPIKey_WithCustomModel(t *testing.T) {
|
||||
mockLogger := NewMockLogger()
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithLogger(mockLogger),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
customModel := "MiniMax-M2.5-highspeed"
|
||||
mmClient.SetAPIKey("sk-test-key-12345678", "", customModel)
|
||||
|
||||
if mmClient.Model != customModel {
|
||||
t.Errorf("Model should be '%s', got '%s'", customModel, mmClient.Model)
|
||||
}
|
||||
|
||||
// Verify logging
|
||||
logs := mockLogger.GetLogsByLevel("INFO")
|
||||
hasCustomModelLog := false
|
||||
for _, log := range logs {
|
||||
if log.Format == "🔧 [MCP] MiniMax using custom Model: %s" {
|
||||
hasCustomModelLog = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasCustomModelLog {
|
||||
t.Error("should have logged custom Model")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test Integration Features
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_CallWithMessages_Success(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetSuccessResponse("MiniMax AI response")
|
||||
mockLogger := NewMockLogger()
|
||||
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(mockLogger),
|
||||
WithAPIKey("sk-test-key"),
|
||||
)
|
||||
|
||||
result, err := client.CallWithMessages("system prompt", "user prompt")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
|
||||
if result != "MiniMax AI response" {
|
||||
t.Errorf("expected 'MiniMax AI response', got '%s'", result)
|
||||
}
|
||||
|
||||
// Verify request
|
||||
requests := mockHTTP.GetRequests()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", len(requests))
|
||||
}
|
||||
|
||||
req := requests[0]
|
||||
|
||||
// Verify URL
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if req.URL.String() != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, req.URL.String())
|
||||
}
|
||||
|
||||
// Verify Authorization header
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if authHeader != "Bearer sk-test-key" {
|
||||
t.Errorf("expected 'Bearer sk-test-key', got '%s'", authHeader)
|
||||
}
|
||||
|
||||
// Verify Content-Type
|
||||
if req.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("Content-Type should be application/json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiniMaxClient_Timeout(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions(
|
||||
WithTimeout(30 * time.Second),
|
||||
)
|
||||
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
if mmClient.httpClient.Timeout != 30*time.Second {
|
||||
t.Errorf("expected timeout 30s, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
|
||||
// Test SetTimeout
|
||||
client.SetTimeout(60 * time.Second)
|
||||
|
||||
if mmClient.httpClient.Timeout != 60*time.Second {
|
||||
t.Errorf("expected timeout 60s after SetTimeout, got %v", mmClient.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Test hooks Mechanism
|
||||
// ============================================================
|
||||
|
||||
func TestMiniMaxClient_HooksIntegration(t *testing.T) {
|
||||
client := NewMiniMaxClientWithOptions()
|
||||
mmClient := client.(*MiniMaxClient)
|
||||
|
||||
// Verify hooks point to mmClient itself (implements polymorphism)
|
||||
if mmClient.hooks != mmClient {
|
||||
t.Error("hooks should point to mmClient for polymorphism")
|
||||
}
|
||||
|
||||
// Verify buildUrl uses MiniMax configuration
|
||||
url := mmClient.buildUrl()
|
||||
expectedURL := DefaultMiniMaxBaseURL + "/chat/completions"
|
||||
if url != expectedURL {
|
||||
t.Errorf("expected URL '%s', got '%s'", expectedURL, url)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
const (
|
||||
ProviderOpenAI = "openai"
|
||||
DefaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
DefaultOpenAIModel = "gpt-5.2"
|
||||
DefaultOpenAIModel = "gpt-5.4"
|
||||
)
|
||||
|
||||
type OpenAIClient struct {
|
||||
|
||||
@@ -22,7 +22,11 @@ func WithLogger(logger Logger) ClientOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets custom HTTP client
|
||||
// WithHTTPClient sets custom HTTP client.
|
||||
//
|
||||
// WARNING: The default client uses security.SafeHTTPClient() with SSRF protection
|
||||
// (blocks private IPs, cloud metadata, validates redirects). Overriding it bypasses
|
||||
// these protections. Only use in tests or with a client providing equivalent safeguards.
|
||||
//
|
||||
// Usage example:
|
||||
// httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
@@ -160,3 +164,17 @@ func WithQwenConfig(apiKey string) ClientOption {
|
||||
c.Model = DefaultQwenModel
|
||||
}
|
||||
}
|
||||
|
||||
// WithMiniMaxConfig sets MiniMax configuration
|
||||
//
|
||||
// Usage example:
|
||||
//
|
||||
// client := mcp.NewClient(mcp.WithMiniMaxConfig("sk-xxx"))
|
||||
func WithMiniMaxConfig(apiKey string) ClientOption {
|
||||
return func(c *Config) {
|
||||
c.Provider = ProviderMiniMax
|
||||
c.APIKey = apiKey
|
||||
c.BaseURL = DefaultMiniMaxBaseURL
|
||||
c.Model = DefaultMiniMaxModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package mcp
|
||||
|
||||
// Message represents a conversation message
|
||||
// Message represents a conversation message.
|
||||
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
|
||||
// and tool result messages (Role="tool", ToolCallID, Content).
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system", "user", "assistant"
|
||||
Content string `json:"content"` // Message content
|
||||
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||
Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages
|
||||
}
|
||||
|
||||
// ToolCall is a single function call requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // Unique call ID (e.g. "call_abc123")
|
||||
Type string `json:"type"` // Always "function"
|
||||
Function ToolCallFunction `json:"function"` // Function name and JSON-serialised arguments
|
||||
}
|
||||
|
||||
// ToolCallFunction holds the function name and raw JSON arguments string.
|
||||
type ToolCallFunction struct {
|
||||
Name string `json:"name"` // Function name
|
||||
Arguments string `json:"arguments"` // JSON-encoded argument object
|
||||
}
|
||||
|
||||
// LLMResponse is returned by CallWithRequestFull and carries both the assistant
|
||||
// text reply (Content) and any structured tool calls (ToolCalls).
|
||||
// Exactly one of the two fields will be non-empty for a well-formed response.
|
||||
type LLMResponse struct {
|
||||
Content string // Plain-text reply (final answer)
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that AI can call
|
||||
|
||||
219
mcp/x402.go
Normal file
219
mcp/x402.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// ── Shared x402 types ────────────────────────────────────────────────────────
|
||||
|
||||
// x402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
|
||||
type x402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []x402AcceptOption `json:"accepts"`
|
||||
Resource *x402Resource `json:"resource"`
|
||||
}
|
||||
|
||||
// x402AcceptOption is a payment option from the x402 v2 header.
|
||||
type x402AcceptOption struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Network string `json:"network"`
|
||||
Amount string `json:"amount"`
|
||||
Asset string `json:"asset"`
|
||||
PayTo string `json:"payTo"`
|
||||
MaxTimeoutSeconds int `json:"maxTimeoutSeconds"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
// x402Resource describes the resource being paid for.
|
||||
type x402Resource struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
MimeType string `json:"mimeType"`
|
||||
}
|
||||
|
||||
// x402SignFunc is a callback that signs an x402 payment header and returns the
|
||||
// base64-encoded payment signature.
|
||||
type x402SignFunc func(paymentHeaderB64 string) (string, error)
|
||||
|
||||
// ── Shared x402 helpers ──────────────────────────────────────────────────────
|
||||
|
||||
// x402DecodeHeader decodes a base64-encoded x402 Payment-Required header,
|
||||
// trying RawStdEncoding first then StdEncoding as fallback.
|
||||
func x402DecodeHeader(b64 string) ([]byte, error) {
|
||||
decoded, err := base64.RawStdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
decoded, err = base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to base64-decode payment header: %w", err)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// signBasePaymentHeader decodes a base64 x402 header, parses it, and signs with
|
||||
// EIP-712 (USDC TransferWithAuthorization). Shared by BlockRunBase and Claw402.
|
||||
func signBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string, providerName string) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("no private key set for %s wallet", providerName)
|
||||
}
|
||||
|
||||
decoded, err := x402DecodeHeader(paymentHeaderB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var req x402v2PaymentRequired
|
||||
if err := json.Unmarshal(decoded, &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse x402 v2 payment header: %w", err)
|
||||
}
|
||||
if len(req.Accepts) == 0 {
|
||||
return "", fmt.Errorf("no payment options in x402 response")
|
||||
}
|
||||
|
||||
senderAddr := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
|
||||
return signX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
|
||||
}
|
||||
|
||||
// doX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
||||
// On a 402 response it reads the Payment-Required (or X-Payment-Required) header,
|
||||
// signs via signFn, retries with Payment-Signature, and logs the Payment-Response
|
||||
// header (tx hash) on success.
|
||||
func doX402Request(
|
||||
httpClient *http.Client,
|
||||
buildReqFn func() (*http.Request, error),
|
||||
signFn x402SignFunc,
|
||||
providerTag string,
|
||||
logger Logger,
|
||||
) ([]byte, error) {
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusPaymentRequired {
|
||||
paymentHeader := resp.Header.Get("Payment-Required")
|
||||
if paymentHeader == "" {
|
||||
paymentHeader = resp.Header.Get("X-Payment-Required")
|
||||
}
|
||||
if paymentHeader == "" {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("received 402 but no Payment-Required header found. Body: %s", string(body))
|
||||
}
|
||||
|
||||
// Drain 402 body to allow HTTP connection reuse.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
|
||||
paymentSig, err := signFn(paymentHeader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign x402 payment: %w", err)
|
||||
}
|
||||
|
||||
req2, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
req2.Header.Set("Payment-Signature", paymentSig)
|
||||
|
||||
resp2, err := httpClient.Do(req2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
body2, err := io.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read payment retry response: %w", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s payment retry failed (status %d): %s", providerTag, resp2.StatusCode, string(body2))
|
||||
}
|
||||
|
||||
if txHash := resp2.Header.Get("Payment-Response"); txHash != "" {
|
||||
logger.Infof("💰 [%s] Payment tx: %s", providerTag, txHash)
|
||||
}
|
||||
|
||||
return body2, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// x402BuildRequest creates a POST request with Content-Type but no auth header.
|
||||
func x402BuildRequest(url string, jsonData []byte) (*http.Request, error) {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail to build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// x402SetAuthHeader is a no-op — x402 providers authenticate via payment signing.
|
||||
func x402SetAuthHeader(_ http.Header) {}
|
||||
|
||||
// x402Call handles the x402 payment flow for the simple CallWithMessages path.
|
||||
func x402Call(c *Client, signFn x402SignFunc, tag string, systemPrompt, userPrompt string) (string, error) {
|
||||
c.logger.Infof("📡 [%s] Request AI Server: %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildMCPRequestBody(systemPrompt, userPrompt)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.hooks.parseMCPResponse(body)
|
||||
}
|
||||
|
||||
// x402CallFull handles the x402 payment flow for the advanced Request path.
|
||||
func x402CallFull(c *Client, signFn x402SignFunc, tag string, req *Request) (*LLMResponse, error) {
|
||||
if c.APIKey == "" {
|
||||
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
|
||||
}
|
||||
if req.Model == "" {
|
||||
req.Model = c.Model
|
||||
}
|
||||
|
||||
c.logger.Infof("📡 [%s] Request AI (full): %s", tag, c.BaseURL)
|
||||
|
||||
requestBody := c.hooks.buildRequestBodyFromRequest(req)
|
||||
jsonData, err := c.hooks.marshalRequestBody(requestBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := doX402Request(c.httpClient, func() (*http.Request, error) {
|
||||
return c.hooks.buildRequest(c.hooks.buildUrl(), jsonData)
|
||||
}, signFn, tag, c.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.hooks.parseMCPResponseFull(body)
|
||||
}
|
||||
@@ -15,11 +15,18 @@ server {
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
# index.html — never cache (so new deploys take effect immediately)
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires 0;
|
||||
}
|
||||
|
||||
# Frontend routes (SPA) with static asset caching
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
# Cache hashed static assets (js/css have content hashes in filenames)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
|
||||
108
provider/coinank/coinank_api/depth_ws.go
Normal file
108
provider/coinank/coinank_api/depth_ws.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
const MainDepthWsUrl = "wss://ws.coinank.com/wsDepth/wsKline"
|
||||
|
||||
type DepthWs struct {
|
||||
conn *websocket.Conn
|
||||
DepthV3Ch <-chan *WsResult[DepthV3]
|
||||
}
|
||||
|
||||
// DepthWsConn connect ws , read data from DepthV3Ch
|
||||
func DepthWsConn(ctx context.Context) (*DepthWs, error) {
|
||||
conn, ch, err := depth_ws(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws := &DepthWs{
|
||||
conn: conn,
|
||||
DepthV3Ch: ch,
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
// Subscribe subscribe depth
|
||||
func (ws *DepthWs) Subscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "subscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnSubscribe unsubscribe depth
|
||||
func (ws *DepthWs) UnSubscribe(symbol string, exchange coinank_enum.Exchange, step string) error {
|
||||
var args = "depthV3@" + symbol + "@" + string(exchange) + "@SWAP@" + step
|
||||
info := SubscribeInfo{
|
||||
Op: "unsubscribe",
|
||||
Args: args,
|
||||
}
|
||||
json, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = websocket.Message.Send(ws.conn, json)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close websocket
|
||||
func (ws *DepthWs) Close() error {
|
||||
return ws.conn.Close()
|
||||
}
|
||||
|
||||
func depth_ws(ctx context.Context) (*websocket.Conn, <-chan *WsResult[DepthV3], error) {
|
||||
config, err := websocket.NewConfig(MainDepthWsUrl, "http://localhost")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
conn, err := config.DialContext(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ch := make(chan *WsResult[DepthV3], 1024)
|
||||
go depth_read(conn, ch)
|
||||
return conn, ch, nil
|
||||
}
|
||||
|
||||
func depth_read(conn *websocket.Conn, ch chan *WsResult[DepthV3]) {
|
||||
defer conn.Close()
|
||||
defer close(ch)
|
||||
var msg string
|
||||
for {
|
||||
err := websocket.Message.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var depth WsResult[DepthV3]
|
||||
err = json.Unmarshal([]byte(msg), &depth)
|
||||
if err == nil {
|
||||
ch <- &depth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DepthV3 struct {
|
||||
Type string `json:"type"`
|
||||
Ts uint64 `json:"ts"`
|
||||
Asks [][]string `json:"asks"`
|
||||
Bids [][]string `json:"bids"`
|
||||
}
|
||||
42
provider/coinank/coinank_api/depth_ws_test.go
Normal file
42
provider/coinank/coinank_api/depth_ws_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package coinank_api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/provider/coinank/coinank_enum"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDepthWs(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
ws, err := DepthWsConn(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go func() {
|
||||
for tickers := range ws.DepthV3Ch {
|
||||
msg, err := json.Marshal(tickers)
|
||||
if err != nil {
|
||||
fmt.Println("json err:", err)
|
||||
}
|
||||
fmt.Println(string(msg))
|
||||
}
|
||||
fmt.Println("DepthV3Ch closed")
|
||||
}()
|
||||
err = ws.Subscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("sub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
err = ws.UnSubscribe("BTCUSDT", coinank_enum.Binance, "0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("unsub success")
|
||||
time.Sleep(10 * time.Second)
|
||||
ws.Close()
|
||||
fmt.Println("cancel success")
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/security"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AI500Config AI500 data provider configuration
|
||||
type AI500Config struct {
|
||||
APIURL string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
var ai500Config = AI500Config{
|
||||
APIURL: "",
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// CoinData coin information
|
||||
type CoinData struct {
|
||||
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
|
||||
Score float64 `json:"score"` // Current score
|
||||
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
|
||||
StartPrice float64 `json:"start_price"` // Start price
|
||||
LastScore float64 `json:"last_score"` // Latest score
|
||||
MaxScore float64 `json:"max_score"` // Highest score
|
||||
MaxPrice float64 `json:"max_price"` // Highest price
|
||||
IncreasePercent float64 `json:"increase_percent"` // Increase percentage
|
||||
IsAvailable bool `json:"-"` // Whether tradable (internal use)
|
||||
}
|
||||
|
||||
// AI500APIResponse raw data structure returned by AI500 API
|
||||
type AI500APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Coins []CoinData `json:"coins"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SetAI500API sets AI500 data provider API
|
||||
func SetAI500API(apiURL string) {
|
||||
ai500Config.APIURL = apiURL
|
||||
}
|
||||
|
||||
// SetOITopAPI sets OI Top API
|
||||
func SetOITopAPI(apiURL string) {
|
||||
oiTopConfig.APIURL = apiURL
|
||||
}
|
||||
|
||||
|
||||
// GetAI500Data retrieves AI500 coin list (with retry mechanism)
|
||||
func GetAI500Data() ([]CoinData, error) {
|
||||
// Check if API URL is configured
|
||||
if strings.TrimSpace(ai500Config.APIURL) == "" {
|
||||
return nil, fmt.Errorf("AI500 API URL not configured")
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
// Try to fetch from API
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
coins, err := fetchAI500()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ Request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all API requests failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// fetchAI500 actually executes AI500 request
|
||||
func fetchAI500() ([]CoinData, error) {
|
||||
log.Printf("🔄 Requesting AI500 data...")
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(ai500Config.APIURL, ai500Config.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.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))
|
||||
}
|
||||
|
||||
// Parse API response
|
||||
var response AI500APIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
if len(response.Data.Coins) == 0 {
|
||||
return nil, fmt.Errorf("coin list is empty")
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
coins := response.Data.Coins
|
||||
for i := range coins {
|
||||
coins[i].IsAvailable = true
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d coins", len(coins))
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
// GetAvailableCoins retrieves available coin list (filters out unavailable ones)
|
||||
func GetAvailableCoins() ([]string, error) {
|
||||
coins, err := GetAI500Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
symbol := normalizeSymbol(coin.Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetTopRatedCoins retrieves top N coins by score (sorted by score descending)
|
||||
func GetTopRatedCoins(limit int) ([]string, error) {
|
||||
coins, err := GetAI500Data()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter available coins
|
||||
var availableCoins []CoinData
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
availableCoins = append(availableCoins, coin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
for i := 0; i < len(availableCoins); i++ {
|
||||
for j := i + 1; j < len(availableCoins); j++ {
|
||||
if availableCoins[i].Score < availableCoins[j].Score {
|
||||
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N
|
||||
maxCount := limit
|
||||
if len(availableCoins) < maxCount {
|
||||
maxCount = len(availableCoins)
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for i := 0; i < maxCount; i++ {
|
||||
symbol := normalizeSymbol(availableCoins[i].Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol normalizes coin symbol
|
||||
func normalizeSymbol(symbol string) string {
|
||||
symbol = trimSpaces(symbol)
|
||||
symbol = toUpper(symbol)
|
||||
if !endsWith(symbol, "USDT") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func trimSpaces(s string) string {
|
||||
result := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != ' ' {
|
||||
result += string(s[i])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toUpper(s string) string {
|
||||
result := ""
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
c = c - 'a' + 'A'
|
||||
}
|
||||
result += string(c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func endsWith(s, suffix string) bool {
|
||||
if len(s) < len(suffix) {
|
||||
return false
|
||||
}
|
||||
return s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
|
||||
|
||||
// ========== OI Top (Open Interest Growth Top 20) Data ==========
|
||||
|
||||
// OIPosition open interest data
|
||||
type OIPosition struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Rank int `json:"rank"`
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
PriceDeltaPercent float64 `json:"price_delta_percent"`
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
}
|
||||
|
||||
// OITopAPIResponse data structure returned by OI Top API
|
||||
type OITopAPIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Positions []OIPosition `json:"positions"`
|
||||
Count int `json:"count"`
|
||||
Exchange string `json:"exchange"`
|
||||
TimeRange string `json:"time_range"`
|
||||
TimeRangeParam string `json:"time_range_param"`
|
||||
RankType string `json:"rank_type"`
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
var oiTopConfig = struct {
|
||||
APIURL string
|
||||
Timeout time.Duration
|
||||
}{
|
||||
APIURL: "",
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// GetOITopPositions retrieves OI Top 20 data (with retry)
|
||||
func GetOITopPositions() ([]OIPosition, error) {
|
||||
if strings.TrimSpace(oiTopConfig.APIURL) == "" {
|
||||
log.Printf("⚠️ OI Top API URL not configured, skipping OI Top data fetch")
|
||||
return []OIPosition{}, nil
|
||||
}
|
||||
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch OI Top data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
positions, err := fetchOITop()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ OI Top request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
log.Printf("⚠️ All OI Top API requests failed (last error: %v), skipping OI Top data", lastErr)
|
||||
return []OIPosition{}, nil
|
||||
}
|
||||
|
||||
// fetchOITop actually executes OI Top request
|
||||
func fetchOITop() ([]OIPosition, error) {
|
||||
log.Printf("🔄 Requesting OI Top data...")
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(oiTopConfig.APIURL, oiTopConfig.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request OI Top API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read OI Top response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("OI Top API returned error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var response OITopAPIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("OI Top JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, fmt.Errorf("OI Top API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
if len(response.Data.Positions) == 0 {
|
||||
return nil, fmt.Errorf("OI Top position list is empty")
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d OI Top coins (time range: %s, type: %s)",
|
||||
len(response.Data.Positions), response.Data.TimeRange, response.Data.RankType)
|
||||
return response.Data.Positions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI Top coin symbol list
|
||||
func GetOITopSymbols() ([]string, error) {
|
||||
positions, err := GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := normalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// MergedData merged data (AI500 + OI Top)
|
||||
type MergedData struct {
|
||||
AI500Coins []CoinData
|
||||
OITopCoins []OIPosition
|
||||
AllSymbols []string
|
||||
SymbolSources map[string][]string
|
||||
}
|
||||
|
||||
// OIRankingData OI ranking data for debate (includes both top and low)
|
||||
type OIRankingData struct {
|
||||
TimeRange string `json:"time_range"`
|
||||
Duration string `json:"duration"`
|
||||
TopPositions []OIPosition `json:"top_positions"`
|
||||
LowPositions []OIPosition `json:"low_positions"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetOIRankingData retrieves OI ranking data (both top increase and low decrease)
|
||||
func GetOIRankingData(baseURL, authKey string, duration string, limit int) (*OIRankingData, error) {
|
||||
if baseURL == "" || authKey == "" {
|
||||
return nil, fmt.Errorf("OI API URL or auth key not configured")
|
||||
}
|
||||
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
result := &OIRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch top ranking
|
||||
topURL := fmt.Sprintf("%s/api/oi/top-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
topPositions, timeRange, err := fetchOIRanking(topURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
|
||||
} else {
|
||||
result.TopPositions = topPositions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch low ranking
|
||||
lowURL := fmt.Sprintf("%s/api/oi/low-ranking?limit=%d&duration=%s&auth=%s", baseURL, limit, duration, authKey)
|
||||
lowPositions, _, err := fetchOIRanking(lowURL)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
|
||||
} else {
|
||||
result.LowPositions = lowPositions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
|
||||
len(result.TopPositions), len(result.LowPositions), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fetchOIRanking fetches OI ranking from a single endpoint
|
||||
func fetchOIRanking(url string) ([]OIPosition, string, error) {
|
||||
// SSRF Protection: Validate URL before making request
|
||||
resp, err := security.SafeGet(url, 30*time.Second)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.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))
|
||||
}
|
||||
|
||||
var response OITopAPIResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != 0 {
|
||||
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data.Positions, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 📊 市场持仓量变化数据 (Open Interest Changes in %s / %s)\n\n", data.TimeRange, data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 🔺 持仓量增加排行 (OI Increase Ranking)\n")
|
||||
sb.WriteString("市场资金正在流入以下币种,可能表示趋势延续或新仓位建立:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**解读**: 持仓增加 + 价格上涨 = 多头主导; 持仓增加 + 价格下跌 = 空头主导\n\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 🔻 持仓量减少排行 (OI Decrease Ranking)\n")
|
||||
sb.WriteString("市场资金正在流出以下币种,可能表示趋势反转或仓位平仓:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化值(USDT) | 变化幅度 | 价格变化 |\n")
|
||||
sb.WriteString("|------|------|------------------|----------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| #%d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank,
|
||||
pos.Symbol,
|
||||
formatOIValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent,
|
||||
pos.PriceDeltaPercent,
|
||||
))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**解读**: 持仓减少 + 价格上涨 = 空头平仓(反弹); 持仓减少 + 价格下跌 = 多头平仓(回调)\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatOIValue formats OI value for display
|
||||
func formatOIValue(v float64) string {
|
||||
sign := ""
|
||||
if v >= 0 {
|
||||
sign = "+"
|
||||
}
|
||||
absV := v
|
||||
if absV < 0 {
|
||||
absV = -absV
|
||||
}
|
||||
if absV >= 1e9 {
|
||||
return fmt.Sprintf("%s%.2fB", sign, v/1e9)
|
||||
} else if absV >= 1e6 {
|
||||
return fmt.Sprintf("%s%.2fM", sign, v/1e6)
|
||||
} else if absV >= 1e3 {
|
||||
return fmt.Sprintf("%s%.2fK", sign, v/1e3)
|
||||
}
|
||||
return fmt.Sprintf("%s%.2f", sign, v)
|
||||
}
|
||||
|
||||
// GetMergedData retrieves merged data (AI500 + OI Top, deduplicated)
|
||||
func GetMergedData(ai500Limit int) (*MergedData, error) {
|
||||
ai500TopSymbols, err := GetTopRatedCoins(ai500Limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to get AI500 data: %v", err)
|
||||
ai500TopSymbols = []string{}
|
||||
}
|
||||
|
||||
oiTopSymbols, err := GetOITopSymbols()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to get OI Top data: %v", err)
|
||||
oiTopSymbols = []string{}
|
||||
}
|
||||
|
||||
symbolSet := make(map[string]bool)
|
||||
symbolSources := make(map[string][]string)
|
||||
|
||||
for _, symbol := range ai500TopSymbols {
|
||||
symbolSet[symbol] = true
|
||||
symbolSources[symbol] = append(symbolSources[symbol], "ai500")
|
||||
}
|
||||
|
||||
for _, symbol := range oiTopSymbols {
|
||||
if !symbolSet[symbol] {
|
||||
symbolSet[symbol] = true
|
||||
}
|
||||
symbolSources[symbol] = append(symbolSources[symbol], "oi_top")
|
||||
}
|
||||
|
||||
var allSymbols []string
|
||||
for symbol := range symbolSet {
|
||||
allSymbols = append(allSymbols, symbol)
|
||||
}
|
||||
|
||||
ai500Coins, _ := GetAI500Data()
|
||||
oiTopPositions, _ := GetOITopPositions()
|
||||
|
||||
merged := &MergedData{
|
||||
AI500Coins: ai500Coins,
|
||||
OITopCoins: oiTopPositions,
|
||||
AllSymbols: allSymbols,
|
||||
SymbolSources: symbolSources,
|
||||
}
|
||||
|
||||
log.Printf("📊 Data merge complete: AI500=%d, OI_Top=%d, Total(deduplicated)=%d",
|
||||
len(ai500TopSymbols), len(oiTopSymbols), len(allSymbols))
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// ========== Backward Compatibility Aliases ==========
|
||||
|
||||
// Deprecated: Use SetAI500API instead
|
||||
func SetCoinPoolAPI(apiURL string) {
|
||||
SetAI500API(apiURL)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetAI500Data instead
|
||||
func GetCoinPool() ([]CoinData, error) {
|
||||
return GetAI500Data()
|
||||
}
|
||||
|
||||
// Deprecated: Use MergedData instead
|
||||
type MergedCoinPool = MergedData
|
||||
|
||||
// Deprecated: Use GetMergedData instead
|
||||
func GetMergedCoinPool(ai500Limit int) (*MergedData, error) {
|
||||
return GetMergedData(ai500Limit)
|
||||
}
|
||||
|
||||
// Deprecated: Use CoinData instead
|
||||
type CoinInfo = CoinData
|
||||
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)
|
||||
}
|
||||
163
provider/nofxos/ai500.go
Normal file
163
provider/nofxos/ai500.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CoinData represents AI500 coin information
|
||||
type CoinData struct {
|
||||
Pair string `json:"pair"` // Trading pair symbol (e.g.: BTCUSDT)
|
||||
Score float64 `json:"score"` // Current AI score (0-100)
|
||||
StartTime int64 `json:"start_time"` // Start time (Unix timestamp)
|
||||
StartPrice float64 `json:"start_price"` // Start price
|
||||
LastScore float64 `json:"last_score"` // Latest score
|
||||
MaxScore float64 `json:"max_score"` // Highest score
|
||||
MaxPrice float64 `json:"max_price"` // Highest price
|
||||
IncreasePercent float64 `json:"increase_percent"` // Increase percentage (already x100)
|
||||
IsAvailable bool `json:"-"` // Whether tradable (internal use)
|
||||
}
|
||||
|
||||
// AI500Response is the API response structure
|
||||
type AI500Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Coins []CoinData `json:"coins"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GetAI500List retrieves AI500 coin list with retry mechanism
|
||||
func (c *Client) GetAI500List() ([]CoinData, error) {
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("⚠️ Retry attempt %d of %d to fetch AI500 data...", attempt, maxRetries)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
coins, err := c.fetchAI500()
|
||||
if err == nil {
|
||||
if attempt > 1 {
|
||||
log.Printf("✓ Retry attempt %d succeeded", attempt)
|
||||
}
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Printf("❌ AI500 request attempt %d failed: %v", attempt, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all AI500 API requests failed: %w", lastErr)
|
||||
}
|
||||
|
||||
func (c *Client) fetchAI500() ([]CoinData, error) {
|
||||
log.Printf("🔄 Requesting AI500 data from %s...", c.GetBaseURL())
|
||||
|
||||
body, err := c.doRequest("/api/ai500/list")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to request AI500 API: %w", err)
|
||||
}
|
||||
|
||||
var response AI500Response
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
// 空列表是正常情况,不是错误
|
||||
if len(response.Data.Coins) == 0 {
|
||||
log.Printf("ℹ️ AI500 returned empty coin list (no coins meet criteria currently)")
|
||||
return []CoinData{}, nil
|
||||
}
|
||||
|
||||
// Set IsAvailable flag
|
||||
coins := response.Data.Coins
|
||||
for i := range coins {
|
||||
coins[i].IsAvailable = true
|
||||
}
|
||||
|
||||
log.Printf("✓ Successfully fetched %d AI500 coins", len(coins))
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
// GetTopRatedCoins retrieves top N coins by score (sorted descending)
|
||||
func (c *Client) GetTopRatedCoins(limit int) ([]string, error) {
|
||||
coins, err := c.GetAI500List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter available coins
|
||||
var availableCoins []CoinData
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
availableCoins = append(availableCoins, coin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(availableCoins) == 0 {
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Sort by Score descending (bubble sort)
|
||||
for i := 0; i < len(availableCoins); i++ {
|
||||
for j := i + 1; j < len(availableCoins); j++ {
|
||||
if availableCoins[i].Score < availableCoins[j].Score {
|
||||
availableCoins[i], availableCoins[j] = availableCoins[j], availableCoins[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take top N
|
||||
maxCount := limit
|
||||
if len(availableCoins) < maxCount {
|
||||
maxCount = len(availableCoins)
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for i := 0; i < maxCount; i++ {
|
||||
symbol := NormalizeSymbol(availableCoins[i].Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetAvailableCoins retrieves all available coin symbols
|
||||
func (c *Client) GetAvailableCoins() ([]string, error) {
|
||||
coins, err := c.GetAI500List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, coin := range coins {
|
||||
if coin.IsAvailable {
|
||||
symbol := NormalizeSymbol(coin.Pair)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty list is normal - just return empty slice, not an error
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// NormalizeSymbol normalizes coin symbol to XXXUSDT format
|
||||
func NormalizeSymbol(symbol string) string {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
symbol = strings.ToUpper(symbol)
|
||||
if !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
return symbol
|
||||
}
|
||||
146
provider/nofxos/client.go
Normal file
146
provider/nofxos/client.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package nofxos provides data access to the NofxOS API (https://nofxos.ai)
|
||||
// for quantitative trading data including AI500 scores, OI rankings,
|
||||
// fund flow (NetFlow), price rankings, and coin details.
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"nofx/security"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Default configuration
|
||||
const (
|
||||
DefaultBaseURL = "https://nofxos.ai"
|
||||
DefaultTimeout = 30 * time.Second
|
||||
DefaultAuthKey = "cm_568c67eae410d912c54c"
|
||||
)
|
||||
|
||||
// Client is the NofxOS API client
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
AuthKey string
|
||||
Timeout time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
defaultClient *Client
|
||||
clientOnce sync.Once
|
||||
)
|
||||
|
||||
// DefaultClient returns the singleton default client
|
||||
func DefaultClient() *Client {
|
||||
clientOnce.Do(func() {
|
||||
defaultClient = &Client{
|
||||
BaseURL: DefaultBaseURL,
|
||||
AuthKey: DefaultAuthKey,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
})
|
||||
return defaultClient
|
||||
}
|
||||
|
||||
// NewClient creates a new NofxOS API client
|
||||
func NewClient(baseURL, authKey string) *Client {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
if authKey == "" {
|
||||
authKey = DefaultAuthKey
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: baseURL,
|
||||
AuthKey: authKey,
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// SetConfig updates client configuration
|
||||
func (c *Client) SetConfig(baseURL, authKey string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if baseURL != "" {
|
||||
c.BaseURL = baseURL
|
||||
}
|
||||
if authKey != "" {
|
||||
c.AuthKey = authKey
|
||||
}
|
||||
}
|
||||
|
||||
// GetBaseURL returns the current base URL
|
||||
func (c *Client) GetBaseURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
// GetAuthKey returns the current auth key
|
||||
func (c *Client) GetAuthKey() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.AuthKey
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP GET request with authentication
|
||||
func (c *Client) doRequest(endpoint string) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
baseURL := c.BaseURL
|
||||
authKey := c.AuthKey
|
||||
timeout := c.Timeout
|
||||
c.mu.RUnlock()
|
||||
|
||||
url := baseURL + endpoint
|
||||
if !strings.Contains(url, "auth=") {
|
||||
if strings.Contains(url, "?") {
|
||||
url += "&auth=" + authKey
|
||||
} else {
|
||||
url += "?auth=" + authKey
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := security.SafeGet(url, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return body, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Message: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// APIError represents an API error response
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ExtractAuthKey extracts auth key from a URL string
|
||||
func ExtractAuthKey(url string) string {
|
||||
if idx := strings.Index(url, "auth="); idx != -1 {
|
||||
authKey := url[idx+5:]
|
||||
if ampIdx := strings.Index(authKey, "&"); ampIdx != -1 {
|
||||
authKey = authKey[:ampIdx]
|
||||
}
|
||||
return authKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
216
provider/nofxos/coin.go
Normal file
216
provider/nofxos/coin.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QuantData represents quantitative data for a single coin
|
||||
type QuantData struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Price float64 `json:"price"`
|
||||
Netflow *NetflowData `json:"netflow,omitempty"`
|
||||
OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit"
|
||||
PriceChange map[string]float64 `json:"price_change,omitempty"` // keyed by duration: "1h", "4h", etc.
|
||||
}
|
||||
|
||||
// NetflowData contains fund flow data
|
||||
type NetflowData struct {
|
||||
Institution *FlowTypeData `json:"institution,omitempty"`
|
||||
Personal *FlowTypeData `json:"personal,omitempty"`
|
||||
}
|
||||
|
||||
// FlowTypeData contains flow data by trade type
|
||||
type FlowTypeData struct {
|
||||
Future map[string]float64 `json:"future,omitempty"` // keyed by duration
|
||||
Spot map[string]float64 `json:"spot,omitempty"` // keyed by duration
|
||||
}
|
||||
|
||||
// OIData contains open interest data for an exchange
|
||||
type OIData struct {
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
Delta map[string]*OIDeltaData `json:"delta,omitempty"` // keyed by duration
|
||||
}
|
||||
|
||||
// OIDeltaData contains OI change data
|
||||
type OIDeltaData struct {
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100
|
||||
}
|
||||
|
||||
// CoinResponse is the API response structure for coin details
|
||||
type CoinResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data *QuantData `json:"data"`
|
||||
}
|
||||
|
||||
// GetCoinData retrieves quantitative data for a single coin
|
||||
func (c *Client) GetCoinData(symbol string, include string) (*QuantData, error) {
|
||||
if symbol == "" {
|
||||
return nil, fmt.Errorf("symbol is required")
|
||||
}
|
||||
|
||||
if include == "" {
|
||||
include = "netflow,oi,price"
|
||||
}
|
||||
|
||||
// Normalize symbol (remove USDT suffix for API call if needed)
|
||||
symbol = strings.TrimSuffix(strings.ToUpper(symbol), "USDT")
|
||||
|
||||
endpoint := fmt.Sprintf("/api/coin/%s?include=%s", symbol, include)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response CoinResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for success (support both success field and code field)
|
||||
if !response.Success && response.Code != 0 {
|
||||
return nil, fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetCoinDataBatch retrieves quantitative data for multiple coins
|
||||
func (c *Client) GetCoinDataBatch(symbols []string, include string) map[string]*QuantData {
|
||||
result := make(map[string]*QuantData)
|
||||
|
||||
for _, symbol := range symbols {
|
||||
data, err := c.GetCoinData(symbol, include)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch coin data for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
if data != nil {
|
||||
// Use normalized symbol as key
|
||||
normalizedSymbol := NormalizeSymbol(symbol)
|
||||
result[normalizedSymbol] = data
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatQuantDataForAI formats single coin quant data for AI consumption
|
||||
func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatQuantDataZH(symbol, data)
|
||||
}
|
||||
return formatQuantDataEN(symbol, data)
|
||||
}
|
||||
|
||||
func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**价格变化**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**机构资金流**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatQuantDataEN(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**Price Change**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %+.2f%%\n", d, change*100))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s OI**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- Current OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- Net Long: %.2f, Net Short: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**Institution Fund Flow**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", d, formatValue(flow)))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
263
provider/nofxos/netflow.go
Normal file
263
provider/nofxos/netflow.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NetFlowPosition represents fund flow data for a single coin
|
||||
type NetFlowPosition struct {
|
||||
Rank int `json:"rank"`
|
||||
Symbol string `json:"symbol"`
|
||||
Amount float64 `json:"amount"` // Fund flow amount in USDT (positive=inflow, negative=outflow)
|
||||
Price float64 `json:"price"`
|
||||
}
|
||||
|
||||
// NetFlowResponse is the API response structure
|
||||
type NetFlowResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Netflows []NetFlowPosition `json:"netflows"`
|
||||
Count int `json:"count"`
|
||||
Type string `json:"type"` // institution or personal
|
||||
Trade string `json:"trade"` // 合约 or 现货
|
||||
TimeRange string `json:"time_range"`
|
||||
RankType string `json:"rank_type"` // top or low
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// NetFlowRankingData contains institution and personal fund flow rankings
|
||||
type NetFlowRankingData struct {
|
||||
Duration string `json:"duration"`
|
||||
TimeRange string `json:"time_range"`
|
||||
InstitutionFutureTop []NetFlowPosition `json:"institution_future_top"`
|
||||
InstitutionFutureLow []NetFlowPosition `json:"institution_future_low"`
|
||||
PersonalFutureTop []NetFlowPosition `json:"personal_future_top"`
|
||||
PersonalFutureLow []NetFlowPosition `json:"personal_future_low"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetNetFlowRanking retrieves NetFlow ranking data (institution/personal, top/low)
|
||||
func (c *Client) GetNetFlowRanking(duration string, limit int) (*NetFlowRankingData, error) {
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
result := &NetFlowRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch institution futures top (inflow)
|
||||
positions, timeRange, err := c.fetchNetFlowRanking("top", duration, limit, "institution", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch institution future inflow ranking: %v", err)
|
||||
} else {
|
||||
result.InstitutionFutureTop = positions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch institution futures low (outflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "institution", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch institution future outflow ranking: %v", err)
|
||||
} else {
|
||||
result.InstitutionFutureLow = positions
|
||||
}
|
||||
|
||||
// Fetch personal futures top (retail inflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("top", duration, limit, "personal", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch personal future inflow ranking: %v", err)
|
||||
} else {
|
||||
result.PersonalFutureTop = positions
|
||||
}
|
||||
|
||||
// Fetch personal futures low (retail outflow)
|
||||
positions, _, err = c.fetchNetFlowRanking("low", duration, limit, "personal", "future")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch personal future outflow ranking: %v", err)
|
||||
} else {
|
||||
result.PersonalFutureLow = positions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched NetFlow ranking data: inst_in=%d, inst_out=%d, retail_in=%d, retail_out=%d (duration: %s)",
|
||||
len(result.InstitutionFutureTop), len(result.InstitutionFutureLow),
|
||||
len(result.PersonalFutureTop), len(result.PersonalFutureLow), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchNetFlowRanking(rankType, duration string, limit int, flowType, trade string) ([]NetFlowPosition, string, error) {
|
||||
endpoint := fmt.Sprintf("/api/netflow/%s-ranking?limit=%d&duration=%s&type=%s&trade=%s",
|
||||
rankType, limit, duration, flowType, trade)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response NetFlowResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, "", fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
return response.Data.Netflows, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// FormatNetFlowRankingForAI formats NetFlow ranking data for AI consumption
|
||||
func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatNetFlowRankingZH(data)
|
||||
}
|
||||
return formatNetFlowRankingEN(data)
|
||||
}
|
||||
|
||||
func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### 机构资金流入榜\n")
|
||||
sb.WriteString("Smart Money买入信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### 机构资金流出榜\n")
|
||||
sb.WriteString("Smart Money卖出信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### 散户资金动向\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("散户买入: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("散户卖出: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatNetFlowRankingEN(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Fund Flow Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### Institution Inflow\n")
|
||||
sb.WriteString("Smart Money buying signals:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Inflow (USDT) | Price |\n")
|
||||
sb.WriteString("|------|--------|---------------|-------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### Institution Outflow\n")
|
||||
sb.WriteString("Smart Money selling signals:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Outflow (USDT) | Price |\n")
|
||||
sb.WriteString("|------|--------|----------------|-------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.Amount), pos.Price))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### Retail Flow\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("Retail buying: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("Retail selling: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s(%s)", pos.Symbol, formatValue(pos.Amount)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: Institution buy + Retail sell = Strong bullish | Institution sell + Retail buy = Strong bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
237
provider/nofxos/oi.go
Normal file
237
provider/nofxos/oi.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OIPosition represents open interest data for a single coin
|
||||
type OIPosition struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Rank int `json:"rank"`
|
||||
Price float64 `json:"price"`
|
||||
CurrentOI float64 `json:"current_oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaPercent float64 `json:"oi_delta_percent"` // Already x100 (5.0 = 5%)
|
||||
OIDeltaValue float64 `json:"oi_delta_value"` // USDT value
|
||||
PriceDeltaPercent float64 `json:"price_delta_percent"` // Already x100 (5.0 = 5%)
|
||||
NetLong float64 `json:"net_long"`
|
||||
NetShort float64 `json:"net_short"`
|
||||
}
|
||||
|
||||
// OIRankingResponse is the API response structure for OI ranking
|
||||
type OIRankingResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Positions []OIPosition `json:"positions"`
|
||||
Count int `json:"count"`
|
||||
Exchange string `json:"exchange"`
|
||||
TimeRange string `json:"time_range"`
|
||||
TimeRangeParam string `json:"time_range_param"`
|
||||
RankType string `json:"rank_type"`
|
||||
Limit int `json:"limit"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// OIRankingData contains both top and low OI rankings
|
||||
type OIRankingData struct {
|
||||
TimeRange string `json:"time_range"`
|
||||
Duration string `json:"duration"`
|
||||
TopPositions []OIPosition `json:"top_positions"`
|
||||
LowPositions []OIPosition `json:"low_positions"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetOIRanking retrieves OI ranking data (both top increase and low decrease)
|
||||
func (c *Client) GetOIRanking(duration string, limit int) (*OIRankingData, error) {
|
||||
if duration == "" {
|
||||
duration = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
result := &OIRankingData{
|
||||
Duration: duration,
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Fetch top ranking (OI increase)
|
||||
topPositions, timeRange, err := c.fetchOIRanking("top", duration, limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI top ranking: %v", err)
|
||||
} else {
|
||||
result.TopPositions = topPositions
|
||||
result.TimeRange = timeRange
|
||||
}
|
||||
|
||||
// Fetch low ranking (OI decrease)
|
||||
lowPositions, _, err := c.fetchOIRanking("low", duration, limit)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to fetch OI low ranking: %v", err)
|
||||
} else {
|
||||
result.LowPositions = lowPositions
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched OI ranking data: %d top, %d low (duration: %s)",
|
||||
len(result.TopPositions), len(result.LowPositions), duration)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchOIRanking(rankType, duration string, limit int) ([]OIPosition, string, error) {
|
||||
endpoint := fmt.Sprintf("/api/oi/%s-ranking?limit=%d&duration=%s", rankType, limit, duration)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response OIRankingResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, "", fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for success (support both success field and code field)
|
||||
if !response.Success && response.Code != 0 {
|
||||
return nil, "", fmt.Errorf("API returned error code: %d", response.Code)
|
||||
}
|
||||
|
||||
return response.Data.Positions, response.Data.TimeRange, nil
|
||||
}
|
||||
|
||||
// GetOITopPositions retrieves top OI increase positions (legacy compatibility)
|
||||
func (c *Client) GetOITopPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("top", "1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOITopSymbols retrieves OI top coin symbol list
|
||||
func (c *Client) GetOITopSymbols() ([]string, error) {
|
||||
positions, err := c.GetOITopPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := NormalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetOILowPositions retrieves OI decrease positions (for short opportunities)
|
||||
func (c *Client) GetOILowPositions() ([]OIPosition, error) {
|
||||
positions, _, err := c.fetchOIRanking("low", "1h", 20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOILowSymbols retrieves OI low coin symbol list
|
||||
func (c *Client) GetOILowSymbols() ([]string, error) {
|
||||
positions, err := c.GetOILowPositions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for _, pos := range positions {
|
||||
symbol := NormalizeSymbol(pos.Symbol)
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// FormatOIRankingForAI formats OI ranking data for AI consumption
|
||||
func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatOIRankingZH(data)
|
||||
}
|
||||
return formatOIRankingEN(data)
|
||||
}
|
||||
|
||||
func formatOIRankingZH(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 持仓增加榜\n")
|
||||
sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 持仓减少榜\n")
|
||||
sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatOIRankingEN(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## Open Interest Changes (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### OI Increase Ranking\n")
|
||||
sb.WriteString("Capital inflow signals - trend continuation or new positions:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
|
||||
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### OI Decrease Ranking\n")
|
||||
sb.WriteString("Capital outflow signals - trend reversal or position closing:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change (USDT) | OI Change % | Price Change % |\n")
|
||||
sb.WriteString("|------|--------|------------------|-------------|----------------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
pos.Rank, pos.Symbol, formatValue(pos.OIDeltaValue),
|
||||
pos.OIDeltaPercent, pos.PriceDeltaPercent))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: OI up + Price up = Bulls dominant | OI up + Price down = Bears dominant | OI down + Price up = Short covering | OI down + Price down = Long liquidation\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
182
provider/nofxos/price.go
Normal file
182
provider/nofxos/price.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PriceRankingItem represents single coin price ranking data
|
||||
type PriceRankingItem struct {
|
||||
Pair string `json:"pair"`
|
||||
Symbol string `json:"symbol"`
|
||||
PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23%
|
||||
Price float64 `json:"price"`
|
||||
FutureFlow float64 `json:"future_flow"`
|
||||
SpotFlow float64 `json:"spot_flow"`
|
||||
OI float64 `json:"oi"`
|
||||
OIDelta float64 `json:"oi_delta"`
|
||||
OIDeltaValue float64 `json:"oi_delta_value"`
|
||||
}
|
||||
|
||||
// PriceRankingDuration contains top gainers and losers for a single duration
|
||||
type PriceRankingDuration struct {
|
||||
Top []PriceRankingItem `json:"top"`
|
||||
Low []PriceRankingItem `json:"low"`
|
||||
}
|
||||
|
||||
// PriceRankingResponse is the API response structure
|
||||
type PriceRankingResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Durations []string `json:"durations"`
|
||||
Limit int `json:"limit"`
|
||||
Data map[string]PriceRankingDuration `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// PriceRankingData contains price ranking data for multiple durations
|
||||
type PriceRankingData struct {
|
||||
Durations map[string]*PriceRankingDuration `json:"durations"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
|
||||
// GetPriceRanking retrieves price ranking data (gainers/losers)
|
||||
func (c *Client) GetPriceRanking(durations string, limit int) (*PriceRankingData, error) {
|
||||
if durations == "" {
|
||||
durations = "1h"
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("/api/price/ranking?duration=%s&limit=%d", durations, limit)
|
||||
|
||||
body, err := c.doRequest(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
|
||||
var response PriceRankingResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("JSON parsing failed: %w", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
return nil, fmt.Errorf("API returned failure status")
|
||||
}
|
||||
|
||||
result := &PriceRankingData{
|
||||
Durations: make(map[string]*PriceRankingDuration),
|
||||
FetchedAt: time.Now(),
|
||||
}
|
||||
|
||||
for duration, data := range response.Data.Data {
|
||||
d := data // Create a copy to avoid pointer issues
|
||||
result.Durations[duration] = &d
|
||||
}
|
||||
|
||||
log.Printf("✓ Fetched Price ranking data for %d durations", len(result.Durations))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FormatPriceRankingForAI formats Price ranking data for AI consumption
|
||||
func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string {
|
||||
if data == nil || len(data.Durations) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
return formatPriceRankingZH(data)
|
||||
}
|
||||
return formatPriceRankingEN(data)
|
||||
}
|
||||
|
||||
func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 涨跌幅排行\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
durationData, exists := data.Durations[duration]
|
||||
if !exists || durationData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**涨幅榜**\n")
|
||||
sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**跌幅榜**\n")
|
||||
sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func formatPriceRankingEN(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Price Gainers/Losers\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
durationData, exists := data.Durations[duration]
|
||||
if !exists || durationData == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**Top Gainers**\n")
|
||||
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
|
||||
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**Top Losers**\n")
|
||||
sb.WriteString("| Symbol | Change | Price | Fund Flow | OI Change |\n")
|
||||
sb.WriteString("|--------|--------|-------|-----------|----------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
item.Symbol, item.PriceDelta*100, item.Price,
|
||||
formatValue(item.FutureFlow), formatValue(item.OIDeltaValue)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**Key**: Big gain + Fund inflow + OI increase = Strong bullish | Big loss + Fund outflow + OI decrease = Strong bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user