mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 17:34:39 +08:00
Compare commits
62 Commits
feat/add-q
...
fcc0267a46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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!
|
||||
|
||||
100
.github/PULL_REQUEST_TEMPLATE.md
vendored
100
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,104 +1,100 @@
|
||||
# Pull Request | PR 提交
|
||||
# Pull Request
|
||||
|
||||
> **📋 选择专用模板 | Choose Specialized Template**
|
||||
> **📋 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
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||
>
|
||||
> **如何使用?| How to use?**
|
||||
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
|
||||
> **How to use?**
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - 或者直接复制粘贴对应模板的内容
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
|
||||
---
|
||||
|
||||
> **💡 提示 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
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 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:** | **中文:**
|
||||
<!-- List the specific changes made -->
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
|
||||
### 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 | 补充说明
|
||||
## 📚 Additional Notes
|
||||
|
||||
**English:** | **中文:**
|
||||
<!-- Any additional information or context -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
**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!**
|
||||
|
||||
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}" \
|
||||
|
||||
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"]
|
||||
115
README.md
115
README.md
@@ -42,6 +42,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 +56,50 @@ 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) |
|
||||
|----------|--------|-------------------------|
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Config Page
|
||||
@@ -87,44 +137,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 +148,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 +488,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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
397
api/server.go
397
api/server.go
@@ -157,6 +157,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
||||
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||||
|
||||
// AI model configuration
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
@@ -202,6 +203,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/trades", s.handleTrades)
|
||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
|
||||
protected.GET("/decisions", s.handleDecisions)
|
||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||
protected.GET("/statistics", s.handleStatistics)
|
||||
@@ -406,7 +408,7 @@ type CreateTraderRequest struct {
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
}
|
||||
|
||||
@@ -486,7 +488,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -666,7 +668,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
TradingSymbols: req.TradingSymbols,
|
||||
UseCoinPool: req.UseCoinPool,
|
||||
UseAI500: req.UseAI500,
|
||||
UseOITop: req.UseOITop,
|
||||
CustomPrompt: req.CustomPrompt,
|
||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||
@@ -682,7 +684,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to create trader", err)
|
||||
return
|
||||
}
|
||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||
@@ -732,7 +734,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
|
||||
var req UpdateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -779,11 +781,13 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval, allow updates
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3
|
||||
}
|
||||
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
||||
|
||||
// Set system prompt template
|
||||
systemPromptTemplate := req.SystemPromptTemplate
|
||||
@@ -818,16 +822,26 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
IsRunning: existingTrader.IsRunning, // Keep original value
|
||||
}
|
||||
|
||||
// Check if trader was running before update (we'll restart it after)
|
||||
wasRunning := false
|
||||
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
|
||||
status := existingMemTrader.GetStatus()
|
||||
if running, ok := status["is_running"].(bool); ok && running {
|
||||
wasRunning = true
|
||||
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, req.StrategyID=%s",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, req.StrategyID)
|
||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
||||
err = s.store.Trader().Update(traderRecord)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to update trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove old trader from memory first to ensure fresh config is loaded
|
||||
// Remove old trader from memory first (this also stops if running)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
|
||||
// Reload traders into memory with fresh config
|
||||
@@ -836,6 +850,18 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||||
}
|
||||
|
||||
// If trader was running before, restart it with new config
|
||||
if wasRunning {
|
||||
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
|
||||
go func() {
|
||||
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
|
||||
if runErr := reloadedTrader.Run(); runErr != nil {
|
||||
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -854,7 +880,7 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
|
||||
// Delete from database
|
||||
err := s.store.Trader().Delete(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete trader: %v", err)})
|
||||
SafeInternalError(c, "Failed to delete trader", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1012,14 +1038,14 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update custom prompt: %v", err)})
|
||||
SafeInternalError(c, "Failed to update custom prompt", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1044,14 +1070,14 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Update database
|
||||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update competition visibility: %v", err)})
|
||||
SafeInternalError(c, "Update competition visibility", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1071,6 +1097,20 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
||||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
||||
return
|
||||
}
|
||||
|
||||
riskInfo := autoTrader.GetGridRiskInfo()
|
||||
c.JSON(http.StatusOK, riskInfo)
|
||||
}
|
||||
|
||||
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -1150,7 +1190,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1158,7 +1198,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||
if balanceErr != nil {
|
||||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to query balance: %v", balanceErr)})
|
||||
SafeInternalError(c, "Failed to query balance", balanceErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1302,7 +1342,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if createErr != nil {
|
||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
||||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1344,7 +1384,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if closeErr != nil {
|
||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close position: %v", closeErr)})
|
||||
SafeInternalError(c, "Close position", closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1428,9 +1468,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
||||
FilledQuantity: quantity,
|
||||
AvgFillPrice: exitPrice,
|
||||
Commission: fee,
|
||||
FilledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
FilledAt: time.Now().UTC().UnixMilli(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
@@ -1458,7 +1498,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
@@ -1533,7 +1573,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
@@ -1582,7 +1622,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
models, err := s.store.AIModel().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get AI model configs: %v", err)})
|
||||
SafeInternalError(c, "Failed to get AI model configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1680,15 +1720,28 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each model's configuration
|
||||
// Update each model's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for modelID, modelData := range req.Models {
|
||||
// Find traders using this AI model BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update model %s: %v", modelID, err)})
|
||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -1706,8 +1759,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get exchange configs: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get exchange configs: %v", err)})
|
||||
SafeInternalError(c, "Failed to get exchange configs", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1801,15 +1853,28 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each exchange's configuration
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update exchange %s: %v", exchangeID, err)})
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -1910,7 +1975,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create exchange account: %v", err)})
|
||||
SafeInternalError(c, "Failed to create exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1953,7 +2018,7 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
||||
err = s.store.Exchange().Delete(userID, exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete exchange account: %v", err)})
|
||||
SafeInternalError(c, "Failed to delete exchange account", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1966,7 +2031,7 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get trader list: %v", err)})
|
||||
SafeInternalError(c, "Failed to get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2019,7 +2084,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Failed to get trader config: %v", err)})
|
||||
SafeNotFound(c, "Trader config")
|
||||
return
|
||||
}
|
||||
traderConfig := fullCfg.Trader
|
||||
@@ -2050,7 +2115,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
"custom_prompt": traderConfig.CustomPrompt,
|
||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||
"use_coin_pool": traderConfig.UseCoinPool,
|
||||
"use_ai500": traderConfig.UseAI500,
|
||||
"use_oi_top": traderConfig.UseOITop,
|
||||
"is_running": isRunning,
|
||||
}
|
||||
@@ -2062,13 +2127,13 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
||||
func (s *Server) handleStatus(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2080,23 +2145,20 @@ func (s *Server) handleStatus(c *gin.Context) {
|
||||
func (s *Server) handleAccount(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
||||
account, err := trader.GetAccountInfo()
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to get account info [%s]: %v", trader.GetName(), err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get account info: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get account info", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2113,21 +2175,19 @@ func (s *Server) handleAccount(c *gin.Context) {
|
||||
func (s *Server) handlePositions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get position list: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get positions", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2138,13 +2198,13 @@ func (s *Server) handlePositions(c *gin.Context) {
|
||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2165,9 +2225,7 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get position history: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2192,13 +2250,13 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
func (s *Server) handleTrades(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2224,9 +2282,7 @@ func (s *Server) handleTrades(c *gin.Context) {
|
||||
|
||||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get trades: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get trades", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2249,13 +2305,13 @@ func (s *Server) handleTrades(c *gin.Context) {
|
||||
func (s *Server) handleOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2280,30 +2336,14 @@ func (s *Server) handleOrders(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all orders for this trader
|
||||
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
|
||||
// Get orders with filters applied at database level
|
||||
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get orders: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by symbol and status if specified
|
||||
result := make([]interface{}, 0)
|
||||
for _, order := range allOrders {
|
||||
// Filter by symbol
|
||||
if symbol != "" && order.Symbol != symbol {
|
||||
continue
|
||||
}
|
||||
// Filter by status
|
||||
if statusFilter != "" && order.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, order)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
c.JSON(http.StatusOK, orders)
|
||||
}
|
||||
|
||||
// handleOrderFills Order fill details (all fills for a specific order)
|
||||
@@ -2317,13 +2357,13 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2336,15 +2376,47 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get order fills: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, fills)
|
||||
}
|
||||
|
||||
// handleOpenOrders Get open orders (pending SL/TP) from exchange
|
||||
func (s *Server) handleOpenOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get symbol parameter (required for exchange query)
|
||||
symbol := c.Query("symbol")
|
||||
if symbol == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol = market.Normalize(symbol)
|
||||
|
||||
// Get open orders from exchange
|
||||
openOrders, err := trader.GetOpenOrders(symbol)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get open orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, openOrders)
|
||||
}
|
||||
|
||||
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||||
func (s *Server) handleKlines(c *gin.Context) {
|
||||
// Get query parameters
|
||||
@@ -2376,30 +2448,21 @@ func (s *Server) handleKlines(c *gin.Context) {
|
||||
// US Stocks via Alpaca
|
||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from Alpaca", err)
|
||||
return
|
||||
}
|
||||
case "forex", "metals":
|
||||
// Forex and Metals via Twelve Data
|
||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from TwelveData", err)
|
||||
return
|
||||
}
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
||||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -2407,10 +2470,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
||||
symbol = market.Normalize(symbol)
|
||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||||
if err != nil {
|
||||
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2728,22 +2788,20 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
||||
func (s *Server) handleDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get all historical decision records (unlimited)
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2754,13 +2812,13 @@ func (s *Server) handleDecisions(c *gin.Context) {
|
||||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2777,9 +2835,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
|
||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get decision log", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2796,21 +2852,19 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||
func (s *Server) handleStatistics(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get statistics: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2829,9 +2883,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2843,7 +2895,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2851,9 +2903,7 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
||||
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get historical data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get historical data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2931,7 +2981,8 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||
// Validate JWT token
|
||||
claims, err := auth.ValidateJWT(tokenString)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token: " + err.Error()})
|
||||
logger.Errorf("[Auth] Invalid token: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
@@ -2979,7 +3030,44 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check max users limit
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
|
||||
existingUser, err := s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
// User exists, check OTP verification status
|
||||
if !existingUser.OTPVerified {
|
||||
// OTP not verified, verify password first for security
|
||||
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
// Password correct, allow user to continue OTP setup
|
||||
// Return existing OTP information
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": existingUser.ID,
|
||||
"email": existingUser.Email,
|
||||
"otp_secret": existingUser.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"message": "Incomplete registration detected, please continue OTP setup",
|
||||
})
|
||||
return
|
||||
}
|
||||
// OTP already verified, reject duplicate registration
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check max users limit (only for new users)
|
||||
maxUsers := config.Get().MaxUsers
|
||||
if maxUsers > 0 {
|
||||
userCount, err := s.store.User().Count()
|
||||
@@ -2993,23 +3081,6 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err := s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate password hash
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
@@ -3036,7 +3107,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
err = s.store.User().Create(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
||||
SafeInternalError(c, "Failed to create user", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3059,14 +3130,14 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3112,7 +3183,7 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3131,10 +3202,15 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
|
||||
// Check if OTP is verified
|
||||
if !user.OTPVerified {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Account has not completed OTP setup",
|
||||
// Return OTP info so user can complete setup
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"otp_secret": user.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"requires_otp_setup": true,
|
||||
"message": "Please complete OTP setup first",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -3156,14 +3232,14 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByID(req.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
||||
SafeNotFound(c, "User")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3197,7 +3273,7 @@ func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3326,9 +3402,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get trader list: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get trader list", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3371,9 +3445,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||
competition, err := s.traderManager.GetCompetitionData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get competition data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3384,9 +3456,7 @@ func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||
func (s *Server) handleTopTraders(c *gin.Context) {
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get top 10 trader data: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get top traders data", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3409,9 +3479,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
||||
// If no trader_ids specified, return historical data for top 5
|
||||
topTraders, err := s.traderManager.GetTopTradersData()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to get top 5 traders: %v", err),
|
||||
})
|
||||
SafeInternalError(c, "Get top traders", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3506,7 +3574,8 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
||||
}
|
||||
if err != nil {
|
||||
errors[traderID] = fmt.Sprintf("Failed to get historical data: %v", err)
|
||||
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
|
||||
errors[traderID] = "Failed to get historical data"
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -151,14 +151,14 @@ func (s *Server) handleCreateStrategy(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
|
||||
}
|
||||
|
||||
// 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,7 +173,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -221,14 +221,14 @@ func (s *Server) handleUpdateStrategy(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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -269,7 +269,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 +287,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 +309,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 +383,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 +396,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 +433,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 +442,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 +503,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 +524,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -166,7 +166,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 +289,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 +335,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 +356,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 +547,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 +1017,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"]`
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **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 +36,50 @@ 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) |
|
||||
|
||||
### 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 デプロイ(推奨)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **웹 기반 설정**: 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 +36,50 @@ 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) |
|
||||
|
||||
### 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 배포 (권장)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Веб-конфигурация**: Без редактирования 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 +36,50 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед началом
|
||||
|
||||
Для использования 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) |
|
||||
|
||||
### 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 развёртывание (рекомендуется)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Веб-конфігурація**: Без редагування 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 +36,50 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед початком
|
||||
|
||||
Для використання 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) |
|
||||
|
||||
### 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 розгортання (рекомендовано)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **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 +36,50 @@ 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) |
|
||||
|
||||
### 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ị)
|
||||
|
||||
@@ -34,6 +34,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 +48,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
## 开始之前
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

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

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

|
||||
*多数据源策略配置与 AI 测试*
|
||||
1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
|
||||
2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek,性价比最高)
|
||||
|
||||
---
|
||||
|
||||
@@ -67,6 +66,7 @@
|
||||
| **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) |
|
||||
|
||||
### Perp-DEX (去中心化永续交易所)
|
||||
|
||||
@@ -92,9 +92,25 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

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

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

|
||||
*多数据源策略配置与 AI 测试*
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 (推荐)
|
||||
### 一键安装 (本地/服务器)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -103,6 +119,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
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,4 +1,4 @@
|
||||
package decision
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/provider"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"regexp"
|
||||
@@ -119,8 +119,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 +130,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 +139,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 +198,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 +222,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 +272,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 +418,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 +427,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 +441,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 +461,40 @@ 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 "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 +513,17 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, symbol := range coinSource.StaticCoins {
|
||||
symbol = market.Normalize(symbol)
|
||||
if _, exists := symbolSources[symbol]; !exists {
|
||||
@@ -471,19 +539,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 +594,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 +616,30 @@ 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -610,50 +728,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 +822,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 +840,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 +852,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 +925,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 +1116,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 +1172,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 +1277,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,13 +1346,38 @@ 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
|
||||
for _, s := range sources {
|
||||
switch s {
|
||||
case "ai500":
|
||||
hasAI500 = true
|
||||
case "oi_top":
|
||||
hasOITop = true
|
||||
case "oi_low":
|
||||
hasOILow = 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)"
|
||||
}
|
||||
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)"
|
||||
}
|
||||
@@ -1612,8 +1849,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 {
|
||||
587
kernel/grid_engine.go
Normal file
587
kernel/grid_engine.go
Normal file
@@ -0,0 +1,587 @@
|
||||
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 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))
|
||||
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))
|
||||
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"
|
||||
2
main.go
2
main.go
@@ -78,7 +78,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)
|
||||
|
||||
@@ -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(),
|
||||
@@ -410,11 +410,18 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
|
||||
|
||||
// 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 +613,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 +648,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,
|
||||
@@ -664,6 +671,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
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":
|
||||
|
||||
@@ -1210,3 +1210,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", 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,42 @@ 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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
165
provider/nofxos/ai500.go
Normal file
165
provider/nofxos/ai500.go
Normal file
@@ -0,0 +1,165 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return nil, fmt.Errorf("no available coins")
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
31
provider/nofxos/util.go
Normal file
31
provider/nofxos/util.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package nofxos
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Language represents the language for formatting output
|
||||
type Language string
|
||||
|
||||
const (
|
||||
LangChinese Language = "zh-CN"
|
||||
LangEnglish Language = "en-US"
|
||||
)
|
||||
|
||||
// formatValue formats a numeric value with sign and appropriate suffix
|
||||
func formatValue(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)
|
||||
}
|
||||
8
railway.toml
Normal file
8
railway.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
dockerfilePath = "Dockerfile.railway"
|
||||
|
||||
[deploy]
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 60
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
restartPolicyMaxRetries = 3
|
||||
57
railway/start.sh
Normal file
57
railway/start.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Railway 会设置 PORT 环境变量
|
||||
export PORT=${PORT:-8080}
|
||||
echo "🚀 Starting NOFX on port $PORT..."
|
||||
|
||||
# 生成加密密钥(如果没有设置)
|
||||
if [ -z "$RSA_PRIVATE_KEY" ]; then
|
||||
export RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null)
|
||||
fi
|
||||
if [ -z "$DATA_ENCRYPTION_KEY" ]; then
|
||||
export DATA_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
fi
|
||||
|
||||
# 生成 nginx 配置
|
||||
cat > /etc/nginx/http.d/default.conf << NGINX_EOF
|
||||
server {
|
||||
listen $PORT;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8081/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
NGINX_EOF
|
||||
|
||||
# 启动后端(端口 8081)
|
||||
API_SERVER_PORT=8081 /app/nofx &
|
||||
sleep 2
|
||||
|
||||
# 启动 nginx(后台)
|
||||
nginx
|
||||
|
||||
echo "✅ NOFX started successfully"
|
||||
|
||||
# 保持容器运行
|
||||
tail -f /dev/null
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"nofx/store"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -83,7 +84,7 @@ func main() {
|
||||
filledOrders++
|
||||
|
||||
// 检查 filled_at
|
||||
if !order.FilledAt.IsZero() {
|
||||
if order.FilledAt > 0 {
|
||||
withFilledAt++
|
||||
} else {
|
||||
missingFilledAt++
|
||||
@@ -119,8 +120,8 @@ func main() {
|
||||
}
|
||||
|
||||
filledAtStr := "N/A"
|
||||
if !order.FilledAt.IsZero() {
|
||||
filledAtStr = order.FilledAt.Format("01-02 15:04")
|
||||
if order.FilledAt > 0 {
|
||||
filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
|
||||
}
|
||||
|
||||
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
||||
|
||||
168
scripts/test_lighter_orders.go
Normal file
168
scripts/test_lighter_orders.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//go:build ignore
|
||||
|
||||
// Test script to verify Lighter API authentication
|
||||
// Run: go run scripts/test_lighter_orders.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configuration - update these values
|
||||
walletAddr := os.Getenv("LIGHTER_WALLET")
|
||||
apiKeyPrivateKey := os.Getenv("LIGHTER_API_KEY")
|
||||
|
||||
if walletAddr == "" || apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go run scripts/test_lighter_orders.go")
|
||||
fmt.Println("Environment variables required:")
|
||||
fmt.Println(" LIGHTER_WALLET - Ethereum wallet address")
|
||||
fmt.Println(" LIGHTER_API_KEY - API key private key (40 bytes hex)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Test ===")
|
||||
fmt.Printf("Wallet: %s\n\n", walletAddr)
|
||||
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info (no auth required)
|
||||
fmt.Println("1. Getting account info...")
|
||||
accountIndex, err := getAccountIndex(client, baseURL, walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: account_index = %d\n\n", accountIndex)
|
||||
|
||||
// Step 2: Create TxClient and generate auth token
|
||||
fmt.Println("2. Creating TxClient and generating auth token...")
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
txClient, err := lighterClient.NewTxClient(httpClient, apiKeyPrivateKey, accountIndex, 0, chainID)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
authToken, err := txClient.GetAuthToken(time.Now().Add(1 * time.Hour))
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: auth token generated\n\n")
|
||||
|
||||
// Step 3: Test GetActiveOrders with auth query parameter (NEW method)
|
||||
fmt.Println("3. Testing GetActiveOrders with auth query parameter (FIXED)...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountIndex, encodedAuth)
|
||||
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
if code, ok := result["code"].(float64); ok && code == 200 {
|
||||
orders := result["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders\n", len(orders))
|
||||
if len(orders) > 0 {
|
||||
fmt.Println(" Sample orders:")
|
||||
for i, o := range orders {
|
||||
if i >= 3 {
|
||||
fmt.Printf(" ... and %d more\n", len(orders)-3)
|
||||
break
|
||||
}
|
||||
order := o.(map[string]interface{})
|
||||
fmt.Printf(" - ID: %v, Price: %v, Side: %v\n",
|
||||
order["order_id"], order["price"], order["is_ask"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body))
|
||||
fmt.Println("\n Possible causes:")
|
||||
fmt.Println(" - API key not registered on-chain")
|
||||
fmt.Println(" - API key private key incorrect")
|
||||
fmt.Println(" - Account index mismatch")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 4: Test GetActiveOrders with Authorization header (OLD method - for comparison)
|
||||
fmt.Println("\n4. Testing GetActiveOrders with Authorization header (OLD method)...")
|
||||
endpoint2 := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0",
|
||||
baseURL, accountIndex)
|
||||
|
||||
req, _ := http.NewRequest("GET", endpoint2, nil)
|
||||
req.Header.Set("Authorization", authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
} else {
|
||||
defer resp2.Body.Close()
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
var result2 map[string]interface{}
|
||||
json.Unmarshal(body2, &result2)
|
||||
|
||||
if code, ok := result2["code"].(float64); ok && code == 200 {
|
||||
orders := result2["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders (both methods work!)\n", len(orders))
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body2))
|
||||
fmt.Println(" ^ This is expected - Authorization header doesn't work consistently")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== TEST COMPLETE ===")
|
||||
fmt.Println("If test 3 passed, the fix is working correctly.")
|
||||
}
|
||||
|
||||
func getAccountIndex(client *http.Client, baseURL, walletAddr string) (int64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Accounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"accounts"`
|
||||
SubAccounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Accounts) > 0 {
|
||||
return result.Accounts[0].AccountIndex, nil
|
||||
}
|
||||
if len(result.SubAccounts) > 0 {
|
||||
return result.SubAccounts[0].AccountIndex, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("no account found")
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"enabled": enabled,
|
||||
"custom_api_url": customAPIURL,
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
|
||||
if apiKey != "" {
|
||||
@@ -167,7 +167,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"enabled": enabled,
|
||||
"custom_api_url": customAPIURL,
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
|
||||
@@ -147,7 +147,7 @@ func (BacktestCheckpoint) TableName() string {
|
||||
type BacktestEquity struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
|
||||
TS int64 `gorm:"column:ts;not null;index:idx_backtest_equity_run_ts"`
|
||||
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_equity_run_ts"`
|
||||
Equity float64 `gorm:"column:equity;not null"`
|
||||
Available float64 `gorm:"column:available;not null"`
|
||||
PnL float64 `gorm:"column:pnl;not null"`
|
||||
@@ -164,7 +164,7 @@ func (BacktestEquity) TableName() string {
|
||||
type BacktestTrade struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
|
||||
TS int64 `gorm:"column:ts;not null;index:idx_backtest_trades_run_ts"`
|
||||
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_trades_run_ts"`
|
||||
Symbol string `gorm:"column:symbol;not null"`
|
||||
Action string `gorm:"column:action;not null"`
|
||||
Side string `gorm:"column:side;default:''"`
|
||||
@@ -217,7 +217,10 @@ func (s *BacktestStore) initTables() error {
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
|
||||
|
||||
if tableExists > 0 {
|
||||
// Tables exist - just ensure indexes exist
|
||||
// Tables exist - fix column types and ensure indexes exist
|
||||
// Fix ts column type from INTEGER to BIGINT (timestamps in milliseconds exceed int4 max)
|
||||
s.db.Exec(`ALTER TABLE backtest_equity ALTER COLUMN ts TYPE BIGINT`)
|
||||
s.db.Exec(`ALTER TABLE backtest_trades ALTER COLUMN ts TYPE BIGINT`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`)
|
||||
|
||||
@@ -236,7 +236,7 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
// Only update encrypted fields if not empty
|
||||
@@ -275,7 +275,7 @@ func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"account_name": accountName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -21,6 +22,10 @@ func DB() *gorm.DB {
|
||||
func InitGorm(dbPath string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||
@@ -53,6 +58,10 @@ func InitGormPostgres(host string, port int, user, password, dbname, sslmode str
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
|
||||
|
||||
585
store/grid.go
Normal file
585
store/grid.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== Grid Store Models ====================
|
||||
// These models mirror the grid package types but are defined here
|
||||
// to avoid import cycles between store and grid packages.
|
||||
|
||||
// GridConfigModel GORM model for grid_configs table
|
||||
type GridConfigModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"index"`
|
||||
TraderID string `json:"trader_id" gorm:"index"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
GridCount int `json:"grid_count" gorm:"default:10"`
|
||||
TotalInvestment float64 `json:"total_investment" gorm:"not null"`
|
||||
Leverage int `json:"leverage" gorm:"default:5"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
UseATRBounds bool `json:"use_atr_bounds" gorm:"default:true"`
|
||||
ATRMultiplier float64 `json:"atr_multiplier" gorm:"default:2.0"`
|
||||
Distribution string `json:"distribution" gorm:"default:gaussian"`
|
||||
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct" gorm:"default:15.0"`
|
||||
StopLossPct float64 `json:"stop_loss_pct" gorm:"default:5.0"`
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct" gorm:"default:10"`
|
||||
MaxPositionSizePct float64 `json:"max_position_size_pct" gorm:"default:30"`
|
||||
|
||||
RegimeCheckInterval int `json:"regime_check_interval" gorm:"default:30"`
|
||||
AutoPauseOnTrend bool `json:"auto_pause_on_trend" gorm:"default:true"`
|
||||
MinRangingScore int `json:"min_ranging_score" gorm:"default:60"`
|
||||
TrendResumeThreshold int `json:"trend_resume_threshold" gorm:"default:70"`
|
||||
|
||||
// Box indicator periods (1h candles)
|
||||
ShortBoxPeriod int `json:"short_box_period" gorm:"default:72"` // 3 days
|
||||
MidBoxPeriod int `json:"mid_box_period" gorm:"default:240"` // 10 days
|
||||
LongBoxPeriod int `json:"long_box_period" gorm:"default:500"` // 21 days
|
||||
|
||||
// Effective leverage limits by regime level
|
||||
NarrowRegimeLeverage int `json:"narrow_regime_leverage" gorm:"default:2"`
|
||||
StandardRegimeLeverage int `json:"standard_regime_leverage" gorm:"default:4"`
|
||||
WideRegimeLeverage int `json:"wide_regime_leverage" gorm:"default:3"`
|
||||
VolatileRegimeLeverage int `json:"volatile_regime_leverage" gorm:"default:2"`
|
||||
|
||||
// Position limits by regime level (percentage of total investment)
|
||||
NarrowRegimePositionPct float64 `json:"narrow_regime_position_pct" gorm:"default:40"`
|
||||
StandardRegimePositionPct float64 `json:"standard_regime_position_pct" gorm:"default:70"`
|
||||
WideRegimePositionPct float64 `json:"wide_regime_position_pct" gorm:"default:60"`
|
||||
VolatileRegimePositionPct float64 `json:"volatile_regime_position_pct" gorm:"default:40"`
|
||||
|
||||
OrderRefreshSec int `json:"order_refresh_sec" gorm:"default:300"`
|
||||
UseMakerOnly bool `json:"use_maker_only" gorm:"default:true"`
|
||||
SlippageTolerPct float64 `json:"slippage_toler_pct" gorm:"default:0.1"`
|
||||
|
||||
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
||||
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
||||
IsActive bool `json:"is_active" gorm:"default:false"`
|
||||
}
|
||||
|
||||
func (GridConfigModel) TableName() string {
|
||||
return "grid_configs"
|
||||
}
|
||||
|
||||
// GridInstanceModel GORM model for grid_instances table
|
||||
type GridInstanceModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
ConfigID string `json:"config_id" gorm:"index;not null"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
StoppedAt *time.Time `json:"stopped_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
CurrentUpperPrice float64 `json:"current_upper_price"`
|
||||
CurrentLowerPrice float64 `json:"current_lower_price"`
|
||||
CurrentGridSpacing float64 `json:"current_grid_spacing"`
|
||||
ActiveLevelCount int `json:"active_level_count"`
|
||||
CurrentRegime string `json:"current_regime"`
|
||||
RegimeScore int `json:"regime_score"`
|
||||
LastRegimeCheck time.Time `json:"last_regime_check"`
|
||||
ConsecutiveTrending int `json:"consecutive_trending"`
|
||||
|
||||
// Current regime level (narrow/standard/wide/volatile/trending)
|
||||
CurrentRegimeLevel string `json:"current_regime_level" gorm:"default:standard"`
|
||||
|
||||
// Box state
|
||||
ShortBoxUpper float64 `json:"short_box_upper"`
|
||||
ShortBoxLower float64 `json:"short_box_lower"`
|
||||
MidBoxUpper float64 `json:"mid_box_upper"`
|
||||
MidBoxLower float64 `json:"mid_box_lower"`
|
||||
LongBoxUpper float64 `json:"long_box_upper"`
|
||||
LongBoxLower float64 `json:"long_box_lower"`
|
||||
|
||||
// Breakout state
|
||||
BreakoutLevel string `json:"breakout_level" gorm:"default:none"` // none/short/mid/long
|
||||
BreakoutDirection string `json:"breakout_direction"` // up/down
|
||||
BreakoutConfirmCount int `json:"breakout_confirm_count" gorm:"default:0"`
|
||||
BreakoutStartTime time.Time `json:"breakout_start_time"`
|
||||
|
||||
// Position adjustment due to breakout
|
||||
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
|
||||
|
||||
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
|
||||
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
||||
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
||||
WinningTrades int `json:"winning_trades" gorm:"default:0"`
|
||||
MaxDrawdown float64 `json:"max_drawdown" gorm:"default:0"`
|
||||
CurrentDrawdown float64 `json:"current_drawdown" gorm:"default:0"`
|
||||
PeakEquity float64 `json:"peak_equity" gorm:"default:0"`
|
||||
DailyProfit float64 `json:"daily_profit" gorm:"default:0"`
|
||||
DailyLoss float64 `json:"daily_loss" gorm:"default:0"`
|
||||
LastDailyReset time.Time `json:"last_daily_reset"`
|
||||
}
|
||||
|
||||
func (GridInstanceModel) TableName() string {
|
||||
return "grid_instances"
|
||||
}
|
||||
|
||||
// GridLevelModel GORM model for grid_levels table
|
||||
type GridLevelModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelIndex int `json:"level_index" gorm:"not null"`
|
||||
Price float64 `json:"price" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
Side string `json:"side"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
OrderPrice float64 `json:"order_price,omitempty"`
|
||||
OrderQuantity float64 `json:"order_quantity,omitempty"`
|
||||
OrderCreatedAt *time.Time `json:"order_created_at,omitempty"`
|
||||
PositionSize float64 `json:"position_size,omitempty"`
|
||||
PositionEntry float64 `json:"position_entry,omitempty"`
|
||||
PositionOpenAt *time.Time `json:"position_open_at,omitempty"`
|
||||
AllocationWeight float64 `json:"allocation_weight"`
|
||||
AllocatedUSD float64 `json:"allocated_usd"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (GridLevelModel) TableName() string {
|
||||
return "grid_levels"
|
||||
}
|
||||
|
||||
// GridEventModel GORM model for grid_events table
|
||||
type GridEventModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelID string `json:"level_id,omitempty" gorm:"index"`
|
||||
EventType string `json:"event_type" gorm:"not null"`
|
||||
EventTime time.Time `json:"event_time" gorm:"autoCreateTime"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Quantity float64 `json:"quantity,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
PnL float64 `json:"pnl,omitempty"`
|
||||
Fee float64 `json:"fee,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
OldRegime string `json:"old_regime,omitempty"`
|
||||
NewRegime string `json:"new_regime,omitempty"`
|
||||
TriggerType string `json:"trigger_type,omitempty"`
|
||||
RawData string `json:"raw_data,omitempty" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridEventModel) TableName() string {
|
||||
return "grid_events"
|
||||
}
|
||||
|
||||
// GridRegimeAssessmentModel GORM model for grid_regime_assessments table
|
||||
type GridRegimeAssessmentModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
AssessedAt time.Time `json:"assessed_at" gorm:"autoCreateTime"`
|
||||
Regime string `json:"regime" gorm:"not null"`
|
||||
Score int `json:"score" gorm:"not null"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BollingerSignal int `json:"bollinger_signal"`
|
||||
EMASignal int `json:"ema_signal"`
|
||||
MACDSignal int `json:"macd_signal"`
|
||||
VolumeSignal int `json:"volume_signal"`
|
||||
OISignal int `json:"oi_signal"`
|
||||
FundingSignal int `json:"funding_signal"`
|
||||
CandleSignal int `json:"candle_signal"`
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerWidth float64 `json:"bollinger_width"`
|
||||
EMADistance float64 `json:"ema_distance"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
AIReasoning string `json:"ai_reasoning" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridRegimeAssessmentModel) TableName() string {
|
||||
return "grid_regime_assessments"
|
||||
}
|
||||
|
||||
// ==================== Grid Store ====================
|
||||
|
||||
// GridStore provides database operations for grid trading
|
||||
type GridStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGridStore creates a new grid store
|
||||
func NewGridStore(db *gorm.DB) *GridStore {
|
||||
return &GridStore{db: db}
|
||||
}
|
||||
|
||||
// InitTables initializes grid-related tables
|
||||
func (s *GridStore) InitTables() error {
|
||||
// For PostgreSQL with existing tables, skip AutoMigrate to avoid type conflicts
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'grid_configs'`).Scan(&tableExists)
|
||||
|
||||
if tableExists > 0 {
|
||||
// Tables exist, just ensure indexes
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_user_id ON grid_configs(user_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_trader_id ON grid_configs(trader_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_instances_config_id ON grid_instances(config_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_levels_instance_id ON grid_levels(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_instance_id ON grid_events(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_level_id ON grid_events(level_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_regime_assessments_instance_id ON grid_regime_assessments(instance_id)`)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate all grid tables
|
||||
if err := s.db.AutoMigrate(
|
||||
&GridConfigModel{},
|
||||
&GridInstanceModel{},
|
||||
&GridLevelModel{},
|
||||
&GridEventModel{},
|
||||
&GridRegimeAssessmentModel{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to migrate grid tables: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Config Operations ====================
|
||||
|
||||
// SaveGridConfig saves or updates a grid configuration
|
||||
func (s *GridStore) SaveGridConfig(config *GridConfigModel) error {
|
||||
config.UpdatedAt = time.Now()
|
||||
if config.CreatedAt.IsZero() {
|
||||
config.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Save(config).Error
|
||||
}
|
||||
|
||||
// LoadGridConfig loads a grid configuration by ID
|
||||
func (s *GridStore) LoadGridConfig(id string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("id = ?", id).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// LoadGridConfigByTrader loads a grid configuration by trader ID
|
||||
func (s *GridStore) LoadGridConfigByTrader(traderID string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("trader_id = ? AND is_active = true", traderID).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ListGridConfigs lists all grid configurations for a user
|
||||
func (s *GridStore) ListGridConfigs(userID string) ([]GridConfigModel, error) {
|
||||
var configs []GridConfigModel
|
||||
err := s.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&configs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// DeleteGridConfig deletes a grid configuration and all related data
|
||||
func (s *GridStore) DeleteGridConfig(id string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Get all instances for this config
|
||||
var instances []GridInstanceModel
|
||||
if err := tx.Where("config_id = ?", id).Find(&instances).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete related data for each instance
|
||||
for _, instance := range instances {
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridLevelModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridEventModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridRegimeAssessmentModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete instances
|
||||
if err := tx.Where("config_id = ?", id).Delete(&GridInstanceModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete config
|
||||
return tx.Where("id = ?", id).Delete(&GridConfigModel{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Instance Operations ====================
|
||||
|
||||
// SaveGridInstance saves or updates a grid instance
|
||||
func (s *GridStore) SaveGridInstance(instance *GridInstanceModel) error {
|
||||
instance.UpdatedAt = time.Now()
|
||||
return s.db.Save(instance).Error
|
||||
}
|
||||
|
||||
// LoadGridInstance loads a grid instance by config ID
|
||||
func (s *GridStore) LoadGridInstance(configID string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// LoadGridInstanceByID loads a grid instance by ID
|
||||
func (s *GridStore) LoadGridInstanceByID(id string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("id = ?", id).First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// ListGridInstances lists all instances for a config
|
||||
func (s *GridStore) ListGridInstances(configID string) ([]GridInstanceModel, error) {
|
||||
var instances []GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
Find(&instances).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ==================== Level Operations ====================
|
||||
|
||||
// SaveGridLevel saves or updates a grid level
|
||||
func (s *GridStore) SaveGridLevel(level *GridLevelModel) error {
|
||||
level.UpdatedAt = time.Now()
|
||||
return s.db.Save(level).Error
|
||||
}
|
||||
|
||||
// SaveGridLevels saves multiple grid levels
|
||||
func (s *GridStore) SaveGridLevels(levels []GridLevelModel) error {
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range levels {
|
||||
levels[i].UpdatedAt = now
|
||||
}
|
||||
return s.db.Save(&levels).Error
|
||||
}
|
||||
|
||||
// LoadGridLevels loads all levels for an instance
|
||||
func (s *GridStore) LoadGridLevels(instanceID string) ([]GridLevelModel, error) {
|
||||
var levels []GridLevelModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("level_index ASC").
|
||||
Find(&levels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
// DeleteGridLevels deletes all levels for an instance
|
||||
func (s *GridStore) DeleteGridLevels(instanceID string) error {
|
||||
return s.db.Where("instance_id = ?", instanceID).Delete(&GridLevelModel{}).Error
|
||||
}
|
||||
|
||||
// ==================== Event Operations ====================
|
||||
|
||||
// SaveGridEvent saves a grid event
|
||||
func (s *GridStore) SaveGridEvent(event *GridEventModel) error {
|
||||
if event.EventTime.IsZero() {
|
||||
event.EventTime = time.Now()
|
||||
}
|
||||
return s.db.Create(event).Error
|
||||
}
|
||||
|
||||
// LoadRecentGridEvents loads recent events for an instance
|
||||
func (s *GridStore) LoadRecentGridEvents(instanceID string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// LoadGridEventsByType loads events of a specific type
|
||||
func (s *GridStore) LoadGridEventsByType(instanceID, eventType string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ? AND event_type = ?", instanceID, eventType).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// CountGridEvents counts events for an instance
|
||||
func (s *GridStore) CountGridEvents(instanceID string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ?", instanceID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ==================== Regime Assessment Operations ====================
|
||||
|
||||
// SaveGridRegimeAssessment saves a regime assessment
|
||||
func (s *GridStore) SaveGridRegimeAssessment(assessment *GridRegimeAssessmentModel) error {
|
||||
if assessment.AssessedAt.IsZero() {
|
||||
assessment.AssessedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(assessment).Error
|
||||
}
|
||||
|
||||
// LoadLatestGridRegime loads the latest regime assessment
|
||||
func (s *GridStore) LoadLatestGridRegime(instanceID string) (*GridRegimeAssessmentModel, error) {
|
||||
var assessment GridRegimeAssessmentModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&assessment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &assessment, nil
|
||||
}
|
||||
|
||||
// LoadGridRegimeHistory loads regime assessment history
|
||||
func (s *GridStore) LoadGridRegimeHistory(instanceID string, limit int) ([]GridRegimeAssessmentModel, error) {
|
||||
var assessments []GridRegimeAssessmentModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&assessments).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assessments, nil
|
||||
}
|
||||
|
||||
// ==================== Statistics Operations ====================
|
||||
|
||||
// GetGridInstanceStatistics returns statistics for an instance
|
||||
func (s *GridStore) GetGridInstanceStatistics(instanceID string) (map[string]interface{}, error) {
|
||||
var instance GridInstanceModel
|
||||
if err := s.db.Where("id = ?", instanceID).First(&instance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count events by type
|
||||
var eventCounts []struct {
|
||||
EventType string
|
||||
Count int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("event_type, count(*) as count").
|
||||
Where("instance_id = ?", instanceID).
|
||||
Group("event_type").
|
||||
Find(&eventCounts)
|
||||
|
||||
eventCountMap := make(map[string]int64)
|
||||
for _, ec := range eventCounts {
|
||||
eventCountMap[ec.EventType] = ec.Count
|
||||
}
|
||||
|
||||
// Get latest regime
|
||||
var latestRegime GridRegimeAssessmentModel
|
||||
s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&latestRegime)
|
||||
|
||||
winRate := 0.0
|
||||
if instance.TotalTrades > 0 {
|
||||
winRate = float64(instance.WinningTrades) / float64(instance.TotalTrades) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"instance_id": instance.ID,
|
||||
"state": instance.State,
|
||||
"started_at": instance.StartedAt,
|
||||
"stopped_at": instance.StoppedAt,
|
||||
"total_profit": instance.TotalProfit,
|
||||
"total_fees": instance.TotalFees,
|
||||
"total_trades": instance.TotalTrades,
|
||||
"winning_trades": instance.WinningTrades,
|
||||
"win_rate": winRate,
|
||||
"max_drawdown": instance.MaxDrawdown,
|
||||
"current_drawdown": instance.CurrentDrawdown,
|
||||
"peak_equity": instance.PeakEquity,
|
||||
"active_level_count": instance.ActiveLevelCount,
|
||||
"current_regime": instance.CurrentRegime,
|
||||
"regime_score": instance.RegimeScore,
|
||||
"event_counts": eventCountMap,
|
||||
"latest_regime_score": latestRegime.Score,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGridPerformanceMetrics returns performance metrics for a time period
|
||||
func (s *GridStore) GetGridPerformanceMetrics(instanceID string, from, to time.Time) (map[string]interface{}, error) {
|
||||
// Count trades in period
|
||||
var tradeCounts struct {
|
||||
TotalFills int64
|
||||
BuyFills int64
|
||||
SellFills int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("count(*) as total_fills, "+
|
||||
"sum(case when side = 'buy' then 1 else 0 end) as buy_fills, "+
|
||||
"sum(case when side = 'sell' then 1 else 0 end) as sell_fills").
|
||||
Where("instance_id = ? AND event_type = 'order_filled' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Scan(&tradeCounts)
|
||||
|
||||
// Sum profit/loss
|
||||
var pnlSum struct {
|
||||
TotalPnL float64
|
||||
TotalFee float64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("coalesce(sum(pnl), 0) as total_pnl, coalesce(sum(fee), 0) as total_fee").
|
||||
Where("instance_id = ? AND event_time BETWEEN ? AND ?", instanceID, from, to).
|
||||
Scan(&pnlSum)
|
||||
|
||||
// Count regime changes
|
||||
var regimeChanges int64
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ? AND event_type = 'regime_change' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Count(®imeChanges)
|
||||
|
||||
return map[string]interface{}{
|
||||
"period_start": from,
|
||||
"period_end": to,
|
||||
"total_fills": tradeCounts.TotalFills,
|
||||
"buy_fills": tradeCounts.BuyFills,
|
||||
"sell_fills": tradeCounts.SellFills,
|
||||
"total_pnl": pnlSum.TotalPnL,
|
||||
"total_fees": pnlSum.TotalFee,
|
||||
"net_pnl": pnlSum.TotalPnL - pnlSum.TotalFee,
|
||||
"regime_changes": regimeChanges,
|
||||
}, nil
|
||||
}
|
||||
191
store/order.go
191
store/order.go
@@ -2,43 +2,44 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TraderOrder order record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderOrder struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
|
||||
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
|
||||
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||
Type string `gorm:"column:type;not null" json:"type"`
|
||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
|
||||
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
|
||||
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
|
||||
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
|
||||
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||
Type string `gorm:"column:type;not null" json:"type"`
|
||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
|
||||
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
|
||||
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name for TraderOrder
|
||||
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
|
||||
}
|
||||
|
||||
// TraderFill trade record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderFill struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
|
||||
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
|
||||
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name for TraderFill
|
||||
@@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error {
|
||||
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col))
|
||||
}
|
||||
|
||||
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
|
||||
// Check if column is still timestamp type before migrating
|
||||
timestampColumns := []struct{ table, col string }{
|
||||
{"trader_orders", "created_at"},
|
||||
{"trader_orders", "updated_at"},
|
||||
{"trader_orders", "filled_at"},
|
||||
{"trader_fills", "created_at"},
|
||||
}
|
||||
for _, c := range timestampColumns {
|
||||
var dataType string
|
||||
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.col).Scan(&dataType)
|
||||
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
|
||||
// Convert timestamp to Unix milliseconds (bigint)
|
||||
s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col))
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure indexes exist
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`)
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`)
|
||||
@@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr
|
||||
"filled_quantity": filledQty,
|
||||
"avg_fill_price": avgPrice,
|
||||
"commission": commission,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if status == "FILLED" {
|
||||
updates["filled_at"] = time.Now()
|
||||
updates["filled_at"] = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error
|
||||
@@ -217,6 +237,27 @@ func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetTraderOrdersFiltered gets trader's order list with optional symbol and status filters
|
||||
func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, status string, limit int) ([]*TraderOrder, error) {
|
||||
var orders []*TraderOrder
|
||||
query := s.db.Where("trader_id = ?", traderID)
|
||||
|
||||
if symbol != "" {
|
||||
query = query.Where("symbol = ?", symbol)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&orders).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query orders: %w", err)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetOrderFills gets order's fill records
|
||||
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
|
||||
var fills []*TraderFill
|
||||
@@ -324,29 +365,59 @@ func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
|
||||
|
||||
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
|
||||
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
|
||||
type symbolMaxID struct {
|
||||
Symbol string
|
||||
MaxTradeID int64
|
||||
type symbolTradeID struct {
|
||||
Symbol string
|
||||
ExchangeTradeID string
|
||||
}
|
||||
var results []symbolMaxID
|
||||
var results []symbolTradeID
|
||||
|
||||
// Query all trade IDs grouped by symbol, find max in Go to avoid database-specific CAST issues
|
||||
// (PostgreSQL INTEGER is 32-bit, can't handle Binance trade IDs > 2.1B)
|
||||
err := s.db.Model(&TraderFill{}).
|
||||
Select("symbol, MAX(CAST(exchange_trade_id AS INTEGER)) as max_trade_id").
|
||||
Select("symbol, exchange_trade_id").
|
||||
Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID).
|
||||
Group("symbol").
|
||||
Find(&results).Error
|
||||
if err != nil {
|
||||
// If CAST fails (non-numeric trade IDs), fallback to string comparison
|
||||
if strings.Contains(err.Error(), "CAST") || strings.Contains(err.Error(), "invalid") {
|
||||
return make(map[string]int64), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query max trade IDs: %w", err)
|
||||
return nil, fmt.Errorf("failed to query trade IDs: %w", err)
|
||||
}
|
||||
|
||||
// Find max trade ID per symbol in Go (handles 64-bit integers properly)
|
||||
result := make(map[string]int64)
|
||||
for _, r := range results {
|
||||
result[r.Symbol] = r.MaxTradeID
|
||||
tradeID, err := strconv.ParseInt(r.ExchangeTradeID, 10, 64)
|
||||
if err != nil {
|
||||
continue // Skip non-numeric trade IDs
|
||||
}
|
||||
if tradeID > result[r.Symbol] {
|
||||
result[r.Symbol] = tradeID
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange
|
||||
// Used to recover sync state after service restart
|
||||
func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) {
|
||||
var fill TraderFill
|
||||
err := s.db.Where("exchange_id = ?", exchangeID).
|
||||
Order("created_at DESC").
|
||||
First(&fill).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fill.CreatedAt, nil
|
||||
}
|
||||
|
||||
// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms)
|
||||
func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) {
|
||||
var symbols []string
|
||||
err := s.db.Model(&TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs).
|
||||
Pluck("symbol", &symbols).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
@@ -25,30 +25,31 @@ type TraderStats struct {
|
||||
}
|
||||
|
||||
// TraderPosition position record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderPosition struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
||||
EntryTime time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"`
|
||||
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
||||
ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||
Source string `gorm:"column:source;default:system" json:"source"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
||||
EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC
|
||||
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
||||
ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||
Source string `gorm:"column:source;default:system" json:"source"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name
|
||||
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
||||
if tableExists > 0 {
|
||||
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
|
||||
// Check if column is still timestamp type before migrating
|
||||
timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"}
|
||||
for _, col := range timestampColumns {
|
||||
var dataType string
|
||||
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType)
|
||||
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
|
||||
// Convert timestamp to Unix milliseconds (bigint)
|
||||
s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col))
|
||||
}
|
||||
}
|
||||
|
||||
// Just ensure index exists
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`)
|
||||
return nil
|
||||
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
||||
|
||||
// ClosePosition closes position
|
||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
||||
now := time.Now()
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": now,
|
||||
"exit_time": nowMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -144,16 +158,19 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
newFee := pos.Fee + addFee
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": newQty,
|
||||
"entry_quantity": newEntryQty,
|
||||
"entry_price": newEntryPrice,
|
||||
"fee": newFee,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ReducePositionQuantity reduces position quantity for partial close
|
||||
// If quantity reaches 0 (or near 0), automatically closes the position
|
||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||
var pos TraderPosition
|
||||
if err := s.db.First(&pos, id).Error; err != nil {
|
||||
@@ -173,24 +190,46 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
|
||||
// Check if position should be fully closed (quantity reduced to ~0)
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
if newQty <= QUANTITY_TOLERANCE {
|
||||
// Auto-close: set status to CLOSED
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": 0,
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"status": "CLOSED",
|
||||
"exit_time": nowMs,
|
||||
"close_reason": "sync",
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": newQty,
|
||||
"fee": newFee,
|
||||
"exit_price": newExitPrice,
|
||||
"realized_pnl": newPnL,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exchange_id": exchangeID,
|
||||
"exchange_type": exchangeType,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ClosePositionFully marks position as fully closed
|
||||
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error {
|
||||
// exitTimeMs is Unix milliseconds UTC
|
||||
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error {
|
||||
var pos TraderPosition
|
||||
if err := s.db.First(&pos, id).Error; err != nil {
|
||||
return fmt.Errorf("failed to get position: %w", err)
|
||||
@@ -205,11 +244,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
|
||||
"quantity": quantity,
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTime,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": totalRealizedPnL,
|
||||
"fee": totalFee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -432,13 +472,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
EntryPrice: pos.EntryPrice,
|
||||
ExitPrice: pos.ExitPrice,
|
||||
RealizedPnL: pos.RealizedPnL,
|
||||
EntryTime: pos.EntryTime.Unix(),
|
||||
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
|
||||
}
|
||||
|
||||
if pos.ExitTime != nil {
|
||||
t.ExitTime = pos.ExitTime.Unix()
|
||||
duration := pos.ExitTime.Sub(pos.EntryTime)
|
||||
t.HoldDuration = formatDuration(duration)
|
||||
if pos.ExitTime > 0 {
|
||||
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
|
||||
durationMs := pos.ExitTime - pos.EntryTime
|
||||
t.HoldDuration = formatDurationMs(durationMs)
|
||||
}
|
||||
|
||||
if pos.EntryPrice > 0 {
|
||||
@@ -457,26 +497,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
|
||||
// formatDuration formats a duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
return formatDurationMs(d.Milliseconds())
|
||||
}
|
||||
|
||||
// formatDurationMs formats a duration in milliseconds
|
||||
func formatDurationMs(ms int64) string {
|
||||
seconds := ms / 1000
|
||||
minutes := seconds / 60
|
||||
hours := minutes / 60
|
||||
days := hours / 24
|
||||
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
if minutes == 0 {
|
||||
if hours < 24 {
|
||||
remainingMins := minutes % 60
|
||||
if remainingMins == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
return fmt.Sprintf("%dh%dm", hours, remainingMins)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
if hours == 0 {
|
||||
remainingHours := hours % 24
|
||||
if remainingHours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd%dh", days, hours)
|
||||
return fmt.Sprintf("%dd%dh", days, remainingHours)
|
||||
}
|
||||
|
||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||
@@ -566,8 +614,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
|
||||
s.WinTrades++
|
||||
}
|
||||
|
||||
if pos.ExitTime != nil {
|
||||
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
||||
if pos.ExitTime > 0 {
|
||||
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
||||
}
|
||||
}
|
||||
@@ -615,7 +663,7 @@ type HoldingTimeStats struct {
|
||||
// GetHoldingTimeStats analyzes performance by holding duration
|
||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||
var positions []TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
||||
}
|
||||
@@ -632,10 +680,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
|
||||
}
|
||||
|
||||
for _, pos := range positions {
|
||||
if pos.ExitTime == nil {
|
||||
if pos.ExitTime == 0 {
|
||||
continue
|
||||
}
|
||||
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours()
|
||||
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
|
||||
|
||||
var rangeKey string
|
||||
switch {
|
||||
@@ -792,12 +840,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
|
||||
|
||||
// Calculate average holding time
|
||||
var positions []TraderPosition
|
||||
s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions)
|
||||
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
|
||||
if len(positions) > 0 {
|
||||
var totalMins float64
|
||||
for _, pos := range positions {
|
||||
if pos.ExitTime != nil {
|
||||
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
||||
if pos.ExitTime > 0 {
|
||||
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||
}
|
||||
}
|
||||
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
||||
@@ -917,6 +965,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
|
||||
}
|
||||
|
||||
// ClosedPnLRecord represents a closed position record from exchange
|
||||
// All time fields use int64 millisecond timestamps (UTC)
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string
|
||||
Side string
|
||||
@@ -926,8 +975,8 @@ type ClosedPnLRecord struct {
|
||||
RealizedPnL float64
|
||||
Fee float64
|
||||
Leverage int
|
||||
EntryTime time.Time
|
||||
ExitTime time.Time
|
||||
EntryTime int64 // Unix milliseconds UTC
|
||||
ExitTime int64 // Unix milliseconds UTC
|
||||
OrderID string
|
||||
CloseType string
|
||||
ExchangeID string
|
||||
@@ -954,7 +1003,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
|
||||
exchangePositionID := record.ExchangeID
|
||||
if exchangePositionID == "" {
|
||||
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL)
|
||||
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
|
||||
}
|
||||
|
||||
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
|
||||
@@ -965,19 +1014,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
return false, nil
|
||||
}
|
||||
|
||||
exitTime := record.ExitTime
|
||||
entryTime := record.EntryTime
|
||||
exitTimeMs := record.ExitTime
|
||||
entryTimeMs := record.EntryTime
|
||||
|
||||
if exitTime.IsZero() || exitTime.Year() < 2000 {
|
||||
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
|
||||
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
|
||||
if exitTimeMs < minValidTime {
|
||||
return false, nil
|
||||
}
|
||||
if entryTime.IsZero() || entryTime.Year() < 2000 {
|
||||
entryTime = exitTime
|
||||
if entryTimeMs < minValidTime {
|
||||
entryTimeMs = exitTimeMs
|
||||
}
|
||||
if entryTime.After(exitTime) {
|
||||
entryTime = exitTime
|
||||
if entryTimeMs > exitTimeMs {
|
||||
entryTimeMs = exitTimeMs
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
pos := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -988,16 +1040,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
Quantity: record.Quantity,
|
||||
EntryQuantity: record.Quantity,
|
||||
EntryPrice: record.EntryPrice,
|
||||
EntryTime: entryTime,
|
||||
EntryTime: entryTimeMs,
|
||||
ExitPrice: record.ExitPrice,
|
||||
ExitOrderID: record.OrderID,
|
||||
ExitTime: &exitTime,
|
||||
ExitTime: exitTimeMs,
|
||||
RealizedPnL: record.RealizedPnL,
|
||||
Fee: record.Fee,
|
||||
Leverage: record.Leverage,
|
||||
Status: "CLOSED",
|
||||
CloseReason: record.CloseType,
|
||||
Source: "sync",
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
|
||||
err = s.db.Create(pos).Error
|
||||
@@ -1011,21 +1065,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetLastClosedPositionTime gets the most recent exit time
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
||||
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
|
||||
var pos TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
|
||||
Order("exit_time DESC").
|
||||
First(&pos).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil {
|
||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
|
||||
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
}
|
||||
|
||||
return *pos.ExitTime, nil
|
||||
return pos.ExitTime, nil
|
||||
}
|
||||
|
||||
// CreateOpenPosition creates an open position
|
||||
@@ -1076,15 +1130,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
}
|
||||
|
||||
// ClosePositionWithAccurateData closes a position with accurate data from exchange
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error {
|
||||
// exitTimeMs is Unix milliseconds UTC
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error {
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTime,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
|
||||
}
|
||||
|
||||
// ProcessTrade processes a single trade and updates position accordingly
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) ProcessTrade(
|
||||
traderID, exchangeID, exchangeType, symbol, side, action string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
if strings.HasPrefix(action, "open_") {
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
|
||||
} else if strings.HasPrefix(action, "close_") {
|
||||
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
|
||||
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOpen handles opening positions (create new or average into existing)
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) handleOpen(
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get existing OPEN position for (symbol, side)
|
||||
@@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen(
|
||||
return fmt.Errorf("failed to get open position: %w", err)
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
if existing == nil {
|
||||
// Create new position
|
||||
position := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()),
|
||||
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
EntryPrice: price,
|
||||
EntryOrderID: orderID,
|
||||
EntryTime: tradeTime,
|
||||
EntryTime: tradeTimeMs,
|
||||
Leverage: 1,
|
||||
Status: "OPEN",
|
||||
Source: "sync",
|
||||
Fee: fee,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
return pb.positionStore.CreateOpenPosition(position)
|
||||
}
|
||||
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
|
||||
}
|
||||
|
||||
// handleClose handles closing positions (partial or full)
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) handleClose(
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get OPEN position
|
||||
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
|
||||
position.ID,
|
||||
finalExitPrice,
|
||||
orderID,
|
||||
tradeTime,
|
||||
tradeTimeMs,
|
||||
totalPnL,
|
||||
totalFee,
|
||||
"sync",
|
||||
|
||||
@@ -28,6 +28,7 @@ type Store struct {
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -156,6 +157,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Order().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize order tables: %w", err)
|
||||
}
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,6 +283,16 @@ func (s *Store) Order() *OrderStore {
|
||||
return s.order
|
||||
}
|
||||
|
||||
// Grid gets grid trading storage
|
||||
func (s *Store) Grid() *GridStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.grid == nil {
|
||||
s.grid = NewGridStore(s.gdb)
|
||||
}
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
@@ -32,6 +32,12 @@ func (Strategy) TableName() string { return "strategies" }
|
||||
|
||||
// StrategyConfig strategy configuration details (JSON structure)
|
||||
type StrategyConfig struct {
|
||||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||||
StrategyType string `json:"strategy_type,omitempty"`
|
||||
|
||||
// language setting: "zh" for Chinese, "en" for English
|
||||
// This determines the language used for data formatting and prompt generation
|
||||
Language string `json:"language,omitempty"`
|
||||
// coin source configuration
|
||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||
// quantitative data configuration
|
||||
@@ -42,6 +48,39 @@ type StrategyConfig struct {
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
// editable sections of System Prompt
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
|
||||
// Grid trading configuration (only used when StrategyType == "grid_trading")
|
||||
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
|
||||
}
|
||||
|
||||
// GridStrategyConfig grid trading specific configuration
|
||||
type GridStrategyConfig struct {
|
||||
// Trading pair (e.g., "BTCUSDT")
|
||||
Symbol string `json:"symbol"`
|
||||
// Number of grid levels (5-50)
|
||||
GridCount int `json:"grid_count"`
|
||||
// Total investment in USDT
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
// Leverage (1-20)
|
||||
Leverage int `json:"leverage"`
|
||||
// Upper price boundary (0 = auto-calculate from ATR)
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
// Lower price boundary (0 = auto-calculate from ATR)
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
// Use ATR to auto-calculate bounds
|
||||
UseATRBounds bool `json:"use_atr_bounds"`
|
||||
// ATR multiplier for bound calculation (default 2.0)
|
||||
ATRMultiplier float64 `json:"atr_multiplier"`
|
||||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||||
Distribution string `json:"distribution"`
|
||||
// Maximum drawdown percentage before emergency exit
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
// Stop loss percentage per position
|
||||
StopLossPct float64 `json:"stop_loss_pct"`
|
||||
// Daily loss limit percentage
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||||
// Use maker-only orders for lower fees
|
||||
UseMakerOnly bool `json:"use_maker_only"`
|
||||
}
|
||||
|
||||
// PromptSectionsConfig editable sections of System Prompt
|
||||
@@ -58,22 +97,25 @@ type PromptSectionsConfig struct {
|
||||
|
||||
// CoinSourceConfig coin source configuration
|
||||
type CoinSourceConfig struct {
|
||||
// source type: "static" | "coinpool" | "oi_top" | "mixed"
|
||||
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
||||
SourceType string `json:"source_type"`
|
||||
// static coin list (used when source_type = "static")
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
// excluded coins list (filtered out from all sources)
|
||||
ExcludedCoins []string `json:"excluded_coins,omitempty"`
|
||||
// whether to use AI500 coin pool
|
||||
UseCoinPool bool `json:"use_coin_pool"`
|
||||
UseAI500 bool `json:"use_ai500"`
|
||||
// AI500 coin pool maximum count
|
||||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
||||
// AI500 coin pool API URL (strategy-level configuration)
|
||||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
||||
// whether to use OI Top
|
||||
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||
// whether to use OI Top (持仓增加榜,适合做多)
|
||||
UseOITop bool `json:"use_oi_top"`
|
||||
// OI Top maximum count
|
||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||
// OI Top API URL (strategy-level configuration)
|
||||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
||||
// whether to use OI Low (持仓减少榜,适合做空)
|
||||
UseOILow bool `json:"use_oi_low"`
|
||||
// OI Low maximum count
|
||||
OILowLimit int `json:"oi_low_limit,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
// IndicatorConfig indicator configuration
|
||||
@@ -101,16 +143,30 @@ type IndicatorConfig struct {
|
||||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||||
// external data sources
|
||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
||||
|
||||
// ========== NofxOS Unified API Configuration ==========
|
||||
// Unified API Key for all NofxOS data sources
|
||||
NofxOSAPIKey string `json:"nofxos_api_key,omitempty"`
|
||||
|
||||
// quantitative data sources (capital flow, position changes, price changes)
|
||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||||
QuantDataAPIURL string `json:"quant_data_api_url,omitempty"` // quantitative data API address
|
||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||
|
||||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
||||
OIRankingAPIURL string `json:"oi_ranking_api_url,omitempty"` // OI ranking API base URL
|
||||
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
||||
|
||||
// NetFlow ranking data (market-wide fund flow rankings - institution/personal)
|
||||
EnableNetFlowRanking bool `json:"enable_netflow_ranking"` // whether to enable NetFlow ranking data
|
||||
NetFlowRankingDuration string `json:"netflow_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||
NetFlowRankingLimit int `json:"netflow_ranking_limit,omitempty"` // number of entries (default 10)
|
||||
|
||||
// Price ranking data (market-wide gainers/losers)
|
||||
EnablePriceRanking bool `json:"enable_price_ranking"` // whether to enable price ranking data
|
||||
PriceRankingDuration string `json:"price_ranking_duration,omitempty"` // durations: "1h" or "1h,4h,24h"
|
||||
PriceRankingLimit int `json:"price_ranking_limit,omitempty"` // number of entries per ranking (default 10)
|
||||
}
|
||||
|
||||
// KlineConfig K-line configuration
|
||||
@@ -172,14 +228,7 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore {
|
||||
}
|
||||
|
||||
func (s *StrategyStore) initTables() error {
|
||||
// For PostgreSQL with existing table, skip AutoMigrate
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'strategies'`).Scan(&tableExists)
|
||||
if tableExists > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// AutoMigrate will add missing columns without dropping existing data
|
||||
return s.db.AutoMigrate(&Strategy{})
|
||||
}
|
||||
|
||||
@@ -190,15 +239,22 @@ func (s *StrategyStore) initDefaultData() error {
|
||||
|
||||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
// Normalize language to "zh" or "en"
|
||||
normalizedLang := "en"
|
||||
if lang == "zh" {
|
||||
normalizedLang = "zh"
|
||||
}
|
||||
|
||||
config := StrategyConfig{
|
||||
Language: normalizedLang,
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "coinpool",
|
||||
UseCoinPool: true,
|
||||
CoinPoolLimit: 10,
|
||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
||||
UseOITop: false,
|
||||
OITopLimit: 20,
|
||||
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
AI500Limit: 10,
|
||||
UseOITop: false,
|
||||
OITopLimit: 10,
|
||||
UseOILow: false,
|
||||
OILowLimit: 10,
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
@@ -222,15 +278,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
BOLLPeriods: []int{20},
|
||||
// NofxOS unified API key
|
||||
NofxOSAPIKey: "cm_568c67eae410d912c54c",
|
||||
// Quant data
|
||||
EnableQuantData: true,
|
||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
||||
EnableQuantOI: true,
|
||||
EnableQuantNetflow: true,
|
||||
// OI ranking data - market-wide OI increase/decrease rankings
|
||||
// OI ranking data
|
||||
EnableOIRanking: true,
|
||||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
||||
OIRankingDuration: "1h",
|
||||
OIRankingLimit: 10,
|
||||
// NetFlow ranking data
|
||||
EnableNetFlowRanking: true,
|
||||
NetFlowRankingDuration: "1h",
|
||||
NetFlowRankingLimit: 10,
|
||||
// Price ranking data
|
||||
EnablePriceRanking: true,
|
||||
PriceRankingDuration: "1h,4h,24h",
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
@@ -305,7 +370,7 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
|
||||
"config": strategy.Config,
|
||||
"is_public": strategy.IsPublic,
|
||||
"config_visible": strategy.ConfigVisible,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ type Trader struct {
|
||||
BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"`
|
||||
AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"`
|
||||
TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,omitempty"`
|
||||
UseCoinPool bool `gorm:"column:use_coin_pool;default:false" json:"use_coin_pool,omitempty"`
|
||||
UseAI500 bool `gorm:"column:use_coin_pool;default:false" json:"use_ai500,omitempty"`
|
||||
UseOITop bool `gorm:"column:use_oi_top;default:false" json:"use_oi_top,omitempty"`
|
||||
CustomPrompt string `gorm:"column:custom_prompt;default:''" json:"custom_prompt,omitempty"`
|
||||
OverrideBasePrompt bool `gorm:"column:override_base_prompt;default:false" json:"override_base_prompt,omitempty"`
|
||||
@@ -124,6 +124,9 @@ func (s *TraderStore) Update(trader *Trader) error {
|
||||
}
|
||||
if trader.ScanIntervalMinutes > 0 {
|
||||
updates["scan_interval_minutes"] = trader.ScanIntervalMinutes
|
||||
fmt.Printf("📊 TraderStore.Update: scan_interval_minutes=%d will be saved\n", trader.ScanIntervalMinutes)
|
||||
} else {
|
||||
fmt.Printf("⚠️ TraderStore.Update: scan_interval_minutes=%d (<=0, NOT updating)\n", trader.ScanIntervalMinutes)
|
||||
}
|
||||
|
||||
return s.db.Model(&Trader{}).
|
||||
@@ -245,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByExchangeID gets traders that use a specific exchange
|
||||
func (s *TraderStore) ListByExchangeID(userID, exchangeID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND exchange_id = ?", userID, exchangeID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByAIModelID gets traders that use a specific AI model
|
||||
func (s *TraderStore) ListByAIModelID(userID, aiModelID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND ai_model_id = ?", userID, aiModelID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
||||
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -68,7 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -85,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -96,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -113,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -125,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -1407,10 +1407,201 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
body, err := t.request("GET", "/fapi/v3/openOrders", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrderID int64 `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"positionSide"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
StopPrice string `json:"stopPrice"`
|
||||
OrigQty string `json:"origQty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
PositionSide: order.PositionSide,
|
||||
Type: order.Type,
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Get precision information
|
||||
prec, err := t.getPrecision(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get precision: %w", err)
|
||||
}
|
||||
|
||||
// Convert to string with correct precision format
|
||||
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
|
||||
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
|
||||
|
||||
// Determine side
|
||||
side := "BUY"
|
||||
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"symbol": req.Symbol,
|
||||
"positionSide": "BOTH",
|
||||
"type": "LIMIT",
|
||||
"side": side,
|
||||
"timeInForce": "GTC",
|
||||
"quantity": qtyStr,
|
||||
"price": priceStr,
|
||||
}
|
||||
|
||||
// Add reduceOnly if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = "true"
|
||||
}
|
||||
|
||||
body, err := t.request("POST", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID
|
||||
orderID := ""
|
||||
if id, ok := result["orderId"].(float64); ok {
|
||||
orderID = fmt.Sprintf("%.0f", id)
|
||||
} else if id, ok := result["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
|
||||
// Extract client order ID
|
||||
clientOrderID := ""
|
||||
if cid, ok := result["clientOrderId"].(string); ok {
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
Side: side,
|
||||
Price: formattedPrice,
|
||||
Quantity: formattedQty,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by order ID
|
||||
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 20
|
||||
}
|
||||
|
||||
// Aster uses public endpoint (no signature required)
|
||||
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"` // [[price, qty], ...]
|
||||
Asks [][]string `json:"asks"` // [[price, qty], ...]
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert string arrays to float64 arrays
|
||||
bids = make([][]float64, len(result.Bids))
|
||||
for i, bid := range result.Bids {
|
||||
if len(bid) >= 2 {
|
||||
price, _ := strconv.ParseFloat(bid[0], 64)
|
||||
qty, _ := strconv.ParseFloat(bid[1], 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
asks = make([][]float64, len(result.Asks))
|
||||
for i, ask := range result.Asks {
|
||||
if len(ask) >= 2 {
|
||||
price, _ := strconv.ParseFloat(ask[0], 64)
|
||||
qty, _ := strconv.ParseFloat(ask[1], 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/decision"
|
||||
"nofx/kernel"
|
||||
"nofx/experience"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -104,7 +104,7 @@ type AutoTrader struct {
|
||||
trader Trader // Use Trader interface (supports multiple platforms)
|
||||
mcpClient mcp.AIClient
|
||||
store *store.Store // Data storage (decision records, etc.)
|
||||
strategyEngine *decision.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||
cycleNumber int // Current cycle number
|
||||
initialBalance float64
|
||||
dailyPnL float64
|
||||
@@ -123,6 +123,7 @@ type AutoTrader struct {
|
||||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||||
lastBalanceSyncTime time.Time // Last balance sync time
|
||||
userID string // User ID
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -310,7 +311,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
if config.StrategyConfig == nil {
|
||||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
||||
}
|
||||
strategyEngine := decision.NewStrategyEngine(config.StrategyConfig)
|
||||
strategyEngine := kernel.NewStrategyEngine(config.StrategyConfig)
|
||||
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||||
|
||||
return &AutoTrader{
|
||||
@@ -419,9 +420,25 @@ func (at *AutoTrader) Run() error {
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Check if this is a grid trading strategy
|
||||
isGridStrategy := at.IsGridStrategy()
|
||||
if isGridStrategy {
|
||||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||||
if err := at.InitializeGrid(); err != nil {
|
||||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||||
return fmt.Errorf("grid initialization failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately on first run
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -435,8 +452,14 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
case <-at.stopMonitorCh:
|
||||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
||||
@@ -511,6 +534,12 @@ func (at *AutoTrader) runCycle() error {
|
||||
return fmt.Errorf("failed to build trading context: %w", err)
|
||||
}
|
||||
|
||||
// 如果没有候选币种,友好提示并跳过本周期
|
||||
if len(ctx.CandidateCoins) == 0 {
|
||||
logger.Infof("ℹ️ No candidate coins available, skipping this cycle")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||
at.saveEquitySnapshot(ctx)
|
||||
|
||||
@@ -524,7 +553,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
|
||||
// 5. Use strategy engine to call AI for decision
|
||||
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
|
||||
aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||||
aiDecision, err := kernel.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced")
|
||||
|
||||
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||||
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
||||
@@ -585,8 +614,8 @@ func (at *AutoTrader) runCycle() error {
|
||||
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
||||
|
||||
// 7. Print AI decisions
|
||||
// logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions))
|
||||
// for i, d := range decision.Decisions {
|
||||
// logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
|
||||
// for i, d := range kernel.Decisions {
|
||||
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||||
// if d.Action == "open_long" || d.Action == "open_short" {
|
||||
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
|
||||
@@ -637,7 +666,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
TakeProfit: d.TakeProfit,
|
||||
Confidence: d.Confidence,
|
||||
Reasoning: d.Reasoning,
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now().UTC(),
|
||||
Success: false,
|
||||
}
|
||||
|
||||
@@ -664,7 +693,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
// buildTradingContext builds trading context
|
||||
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
// 1. Get account information
|
||||
balance, err := at.trader.GetBalance()
|
||||
if err != nil {
|
||||
@@ -701,7 +730,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||
}
|
||||
|
||||
var positionInfos []decision.PositionInfo
|
||||
var positionInfos []kernel.PositionInfo
|
||||
totalMarginUsed := 0.0
|
||||
|
||||
// Current position key set (for cleaning up closed position records)
|
||||
@@ -744,8 +773,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
// Priority 1: Get from database (trader_positions table) - most accurate
|
||||
if at.store != nil {
|
||||
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||||
if !dbPos.EntryTime.IsZero() {
|
||||
updateTime = dbPos.EntryTime.UnixMilli()
|
||||
if dbPos.EntryTime > 0 {
|
||||
updateTime = dbPos.EntryTime
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,7 +797,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
peakPnlPct := at.peakPnLCache[posKey]
|
||||
at.peakPnLCacheMutex.RUnlock()
|
||||
|
||||
positionInfos = append(positionInfos, decision.PositionInfo{
|
||||
positionInfos = append(positionInfos, kernel.PositionInfo{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
@@ -820,13 +849,13 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
|
||||
|
||||
// 6. Build context
|
||||
ctx := &decision.Context{
|
||||
ctx := &kernel.Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||
CallCount: at.callCount,
|
||||
BTCETHLeverage: btcEthLeverage,
|
||||
AltcoinLeverage: altcoinLeverage,
|
||||
Account: decision.AccountInfo{
|
||||
Account: kernel.AccountInfo{
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
UnrealizedPnL: totalUnrealizedProfit,
|
||||
@@ -859,7 +888,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, kernel.RecentOrder{
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
EntryPrice: trade.EntryPrice,
|
||||
@@ -881,7 +910,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
} else if stats.TotalTrades == 0 {
|
||||
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
||||
} else {
|
||||
ctx.TradingStats = &decision.TradingStats{
|
||||
ctx.TradingStats = &kernel.TradingStats{
|
||||
TotalTrades: stats.TotalTrades,
|
||||
WinRate: stats.WinRate,
|
||||
ProfitFactor: stats.ProfitFactor,
|
||||
@@ -899,7 +928,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
}
|
||||
|
||||
// 8. Get quantitative data (if enabled in strategy config)
|
||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
||||
if strategyConfig.Indicators.EnableQuantData {
|
||||
// Collect symbols to query (candidate coins + position coins)
|
||||
symbolsToQuery := make(map[string]bool)
|
||||
for _, coin := range candidateCoins {
|
||||
@@ -929,11 +958,31 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Get NetFlow ranking data (market-wide fund flow)
|
||||
if strategyConfig.Indicators.EnableNetFlowRanking {
|
||||
logger.Infof("💰 [%s] Fetching NetFlow ranking data...", at.name)
|
||||
ctx.NetFlowRankingData = at.strategyEngine.FetchNetFlowRankingData()
|
||||
if ctx.NetFlowRankingData != nil {
|
||||
logger.Infof("💰 [%s] NetFlow ranking data ready: inst_in=%d, inst_out=%d",
|
||||
at.name, len(ctx.NetFlowRankingData.InstitutionFutureTop), len(ctx.NetFlowRankingData.InstitutionFutureLow))
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Get Price ranking data (market-wide gainers/losers)
|
||||
if strategyConfig.Indicators.EnablePriceRanking {
|
||||
logger.Infof("📈 [%s] Fetching Price ranking data...", at.name)
|
||||
ctx.PriceRankingData = at.strategyEngine.FetchPriceRankingData()
|
||||
if ctx.PriceRankingData != nil {
|
||||
logger.Infof("📈 [%s] Price ranking data ready for %d durations",
|
||||
at.name, len(ctx.PriceRankingData.Durations))
|
||||
}
|
||||
}
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// executeDecisionWithRecord executes AI decision and records detailed information
|
||||
func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeDecisionWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
switch decision.Action {
|
||||
case "open_long":
|
||||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
||||
@@ -953,7 +1002,7 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
|
||||
|
||||
// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
|
||||
// This is a public method that can be called by other modules
|
||||
func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error {
|
||||
func (at *AutoTrader) ExecuteDecision(d *kernel.Decision) error {
|
||||
logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
|
||||
|
||||
// Create a minimal action record for tracking
|
||||
@@ -979,7 +1028,7 @@ func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error {
|
||||
}
|
||||
|
||||
// executeOpenLongWithRecord executes open long position and records detailed information
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
@@ -1096,7 +1145,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
|
||||
}
|
||||
|
||||
// executeOpenShortWithRecord executes open short position and records detailed information
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeOpenShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||||
|
||||
// ⚠️ Get current positions for multiple checks
|
||||
@@ -1213,7 +1262,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
|
||||
}
|
||||
|
||||
// executeCloseLongWithRecord executes close long position and records detailed information
|
||||
func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeCloseLongWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
@@ -1277,7 +1326,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
|
||||
}
|
||||
|
||||
// executeCloseShortWithRecord executes close short position and records detailed information
|
||||
func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||
func (at *AutoTrader) executeCloseShortWithRecord(decision *kernel.Decision, actionRecord *store.DecisionAction) error {
|
||||
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||
|
||||
// Get current price
|
||||
@@ -1345,6 +1394,12 @@ func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
}
|
||||
|
||||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||||
// This is used by grid trading and other components that need direct exchange access
|
||||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||||
return at.trader
|
||||
}
|
||||
|
||||
// GetName gets trader name
|
||||
func (at *AutoTrader) GetName() string {
|
||||
return at.name
|
||||
@@ -1392,7 +1447,7 @@ func (at *AutoTrader) GetSystemPromptTemplate() string {
|
||||
}
|
||||
|
||||
// saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision)
|
||||
func (at *AutoTrader) saveEquitySnapshot(ctx *decision.Context) {
|
||||
func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
|
||||
if at.store == nil || ctx == nil {
|
||||
return
|
||||
}
|
||||
@@ -1451,7 +1506,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
isRunning := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"trader_id": at.id,
|
||||
"trader_name": at.name,
|
||||
"ai_model": at.aiModel,
|
||||
@@ -1466,6 +1521,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||||
"ai_provider": aiProvider,
|
||||
}
|
||||
|
||||
// Add strategy info
|
||||
if at.config.StrategyConfig != nil {
|
||||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||||
if at.config.StrategyConfig.GridConfig != nil {
|
||||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountInfo gets account information (for API)
|
||||
@@ -1624,7 +1689,7 @@ func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 {
|
||||
|
||||
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
|
||||
// This avoids position stacking overflow when changing positions
|
||||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
||||
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||
if len(decisions) <= 1 {
|
||||
return decisions
|
||||
}
|
||||
@@ -1644,7 +1709,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
||||
}
|
||||
|
||||
// Copy decision list
|
||||
sorted := make([]decision.Decision, len(decisions))
|
||||
sorted := make([]kernel.Decision, len(decisions))
|
||||
copy(sorted, decisions)
|
||||
|
||||
// Sort by priority
|
||||
@@ -1947,6 +2012,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
switch action {
|
||||
case "open_long", "open_short":
|
||||
// Open position: create new position record
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
pos := &store.TraderPosition{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchangeID, // Exchange account UUID
|
||||
@@ -1956,9 +2022,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
Quantity: quantity,
|
||||
EntryPrice: price,
|
||||
EntryOrderID: orderID,
|
||||
EntryTime: time.Now(),
|
||||
EntryTime: nowMs,
|
||||
Leverage: leverage,
|
||||
Status: "OPEN",
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
if err := at.store.Position().Create(pos); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
||||
@@ -1976,7 +2044,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
at.id, at.exchangeID, at.exchange,
|
||||
symbol, side, action,
|
||||
quantity, price, fee, 0, // realizedPnL will be calculated
|
||||
time.Now(), orderID,
|
||||
time.Now().UTC().UnixMilli(), orderID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||||
} else {
|
||||
@@ -2029,8 +2097,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
|
||||
ReduceOnly: reduceOnly,
|
||||
ClosePosition: reduceOnly,
|
||||
OrderAction: orderAction,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2071,7 +2139,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0, // Will be calculated for close orders
|
||||
IsMaker: false, // Market orders are usually taker
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
// Calculate realized PnL for close orders
|
||||
@@ -2195,3 +2263,8 @@ func getSideFromAction(action string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GetOpenOrders returns open orders (pending SL/TP) from exchange
|
||||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
return at.trader.GetOpenOrders(symbol)
|
||||
}
|
||||
|
||||
|
||||
1579
trader/auto_trader_grid.go
Normal file
1579
trader/auto_trader_grid.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -716,6 +716,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price to correct precision
|
||||
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side and position side
|
||||
var side futures.SideType
|
||||
var positionSide futures.PositionSideType
|
||||
|
||||
if req.Side == "BUY" {
|
||||
side = futures.SideTypeBuy
|
||||
positionSide = futures.PositionSideTypeLong
|
||||
} else {
|
||||
side = futures.SideTypeSell
|
||||
positionSide = futures.PositionSideTypeShort
|
||||
}
|
||||
|
||||
// Build order service with broker ID
|
||||
orderService := t.client.NewCreateOrderService().
|
||||
Symbol(req.Symbol).
|
||||
Side(side).
|
||||
PositionSide(positionSide).
|
||||
Type(futures.OrderTypeLimit).
|
||||
TimeInForce(futures.TimeInForceTypeGTC).
|
||||
Quantity(quantityStr).
|
||||
Price(priceStr).
|
||||
NewClientOrderID(getBrOrderID())
|
||||
|
||||
// Execute order
|
||||
order, err := orderService.Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: string(order.Status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
|
||||
// Parse order ID to int64
|
||||
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.client.NewCancelOrderService().
|
||||
Symbol(symbol).
|
||||
OrderID(orderIDInt).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
book, err := t.client.NewDepthService().
|
||||
Symbol(symbol).
|
||||
Limit(depth).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert bids
|
||||
bids = make([][]float64, len(book.Bids))
|
||||
for i, bid := range book.Bids {
|
||||
price, _ := strconv.ParseFloat(bid.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
// Convert asks
|
||||
asks = make([][]float64, len(book.Asks))
|
||||
for i, ask := range book.Asks {
|
||||
price, _ := strconv.ParseFloat(ask.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
|
||||
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
@@ -776,6 +895,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get legacy open orders
|
||||
orders, err := t.client.NewListOpenOrdersService().
|
||||
Symbol(symbol).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Type: string(order.Type),
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Get Algo orders (new API for stop-loss/take-profit)
|
||||
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
|
||||
Symbol(symbol).
|
||||
Do(context.Background())
|
||||
|
||||
if err == nil {
|
||||
for _, algoOrder := range algoOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||||
Symbol: algoOrder.Symbol,
|
||||
Side: string(algoOrder.Side),
|
||||
PositionSide: string(algoOrder.PositionSide),
|
||||
Type: string(algoOrder.OrderType),
|
||||
Price: 0, // Algo orders use stop price
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets market price
|
||||
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
|
||||
@@ -977,6 +1154,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
|
||||
return fmt.Sprintf(format, quantity), nil
|
||||
}
|
||||
|
||||
// GetSymbolPricePrecision gets the price precision for a trading pair
|
||||
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
|
||||
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get trading rules: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range exchangeInfo.Symbols {
|
||||
if s.Symbol == symbol {
|
||||
// Get precision from PRICE_FILTER filter
|
||||
for _, filter := range s.Filters {
|
||||
if filter["filterType"] == "PRICE_FILTER" {
|
||||
tickSize := filter["tickSize"].(string)
|
||||
precision := calculatePrecision(tickSize)
|
||||
return precision, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2 decimal places for price
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
// FormatPrice formats price to correct precision
|
||||
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
|
||||
precision, err := t.GetSymbolPricePrecision(symbol)
|
||||
if err != nil {
|
||||
// If retrieval fails, use default format
|
||||
return fmt.Sprintf("%.2f", price), nil
|
||||
}
|
||||
|
||||
format := fmt.Sprintf("%%.%df", precision)
|
||||
return fmt.Sprintf(format, price), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && stringContains(s, substr)
|
||||
@@ -1122,7 +1335,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
||||
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||
Symbol: income.Symbol,
|
||||
RealizedPnL: pnl,
|
||||
Time: time.UnixMilli(income.Time),
|
||||
Time: time.UnixMilli(income.Time).UTC(),
|
||||
// Note: Income API doesn't provide price, quantity, side, fee
|
||||
// For accurate data, use GetTradesForSymbol with specific symbol
|
||||
}
|
||||
@@ -1167,7 +1380,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -1210,7 +1423,7 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -1244,3 +1457,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
|
||||
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
|
||||
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
|
||||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||
IncomeType("REALIZED_PNL").
|
||||
StartTime(lastSyncTime.UnixMilli()).
|
||||
Limit(1000).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get PnL history: %w", err)
|
||||
}
|
||||
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, income := range incomes {
|
||||
if income.Symbol != "" {
|
||||
symbolMap[income.Symbol] = true
|
||||
}
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for symbol := range symbolMap {
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// syncState stores the last sync time for incremental sync
|
||||
// syncState stores the last sync time (Unix ms) for incremental sync
|
||||
var (
|
||||
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime
|
||||
binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
|
||||
binanceSyncStateMutex sync.RWMutex
|
||||
)
|
||||
|
||||
@@ -25,43 +25,101 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get last sync time (default to 24 hours ago for first sync)
|
||||
orderStore := st.Order()
|
||||
|
||||
// Get last sync time (Unix ms) - first try memory, then database, then default
|
||||
binanceSyncStateMutex.RLock()
|
||||
lastSyncTime, exists := binanceSyncState[exchangeID]
|
||||
lastSyncTimeMs, exists := binanceSyncState[exchangeID]
|
||||
binanceSyncStateMutex.RUnlock()
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
if !exists {
|
||||
lastSyncTime = time.Now().Add(-24 * time.Hour)
|
||||
// Try to get last fill time from database (persist across restarts)
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err == nil && lastFillTimeMs > 0 {
|
||||
// If recovered time is in the future, it's clearly wrong - use default
|
||||
if lastFillTimeMs > nowMs {
|
||||
logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default",
|
||||
lastFillTimeMs, nowMs)
|
||||
lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago
|
||||
} else {
|
||||
// Add 1 second buffer to avoid re-fetching the same fill
|
||||
lastSyncTimeMs = lastFillTimeMs + 1000
|
||||
logger.Infof("📅 Recovered last sync time from DB: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
} else {
|
||||
// First sync: go back 24 hours
|
||||
lastSyncTimeMs = nowMs - 24*60*60*1000
|
||||
logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||
syncStartTime := time.Now()
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
|
||||
|
||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||
orderStore := st.Order()
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
||||
maxTradeIDs = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call)
|
||||
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||
// Step 2: Detect symbols to sync using multiple methods
|
||||
// COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades)
|
||||
symbolMap := make(map[string]bool)
|
||||
lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls
|
||||
|
||||
// Method 1: COMMISSION income detection
|
||||
commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err)
|
||||
// Fallback: only sync symbols with active positions
|
||||
changedSymbols = t.getPositionSymbols()
|
||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Always include active positions (catches trades that COMMISSION missed)
|
||||
positionSymbols := t.getPositionSymbols()
|
||||
logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols)
|
||||
for _, s := range positionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 3: Include symbols from recent fills in DB (in case some were partially synced)
|
||||
recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs)
|
||||
logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols)
|
||||
for _, s := range recentSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 4: ALWAYS query REALIZED_PNL income to find symbols with closed trades
|
||||
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||
// IMPORTANT: Must run always, not just when symbolMap is empty,
|
||||
// because a position might be fully closed (no active position) but have PnL
|
||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var changedSymbols []string
|
||||
for s := range symbolMap {
|
||||
changedSymbols = append(changedSymbols, s)
|
||||
}
|
||||
|
||||
if len(changedSymbols) == 0 {
|
||||
logger.Infof("📭 No symbols with new trades to sync")
|
||||
// Update last sync time even if no changes
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncStateMutex.Unlock()
|
||||
// DON'T update lastSyncTime to current time here!
|
||||
// Keep using the last actual trade time from DB to avoid creating gaps
|
||||
// The lastSyncTimeMs from DB already has +1000ms buffer added
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,23 +152,18 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
||||
|
||||
// Only update last sync time if ALL symbols were successfully queried
|
||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||
if len(failedSymbols) == 0 {
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncStateMutex.Unlock()
|
||||
} else {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
if len(allTrades) == 0 {
|
||||
// No trades returned, but symbols were detected - might be false positive from COMMISSION/PnL detection
|
||||
// Don't update lastSyncTime, keep using DB value
|
||||
if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(allTrades, func(i, j int) bool {
|
||||
return allTrades[i].Time.Before(allTrades[j].Time)
|
||||
return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one
|
||||
@@ -118,10 +171,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
|
||||
skippedCount := 0
|
||||
for _, trade := range allTrades {
|
||||
// Check if trade already exists
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
skippedCount++
|
||||
continue // Trade already exists, skip
|
||||
}
|
||||
|
||||
@@ -145,7 +200,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
// Normalize side
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -162,9 +218,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -173,7 +229,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -190,7 +246,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -202,7 +258,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
@@ -210,11 +266,26 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)",
|
||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction,
|
||||
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||
}
|
||||
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||
// Update lastSyncTime to the LATEST trade time (not current time!)
|
||||
// This ensures next sync starts from where we left off, not from "now"
|
||||
// allTrades is already sorted by time ASC, so last element is the latest
|
||||
if len(allTrades) > 0 && len(failedSymbols) == 0 {
|
||||
latestTradeTimeMs := allTrades[len(allTrades)-1].Time.UTC().UnixMilli()
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = latestTradeTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
logger.Infof("📅 Updated lastSyncTime to latest trade: %s (UTC)",
|
||||
time.UnixMilli(latestTradeTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
} else if len(failedSymbols) > 0 {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced, %d skipped (already exist)", syncedCount, skippedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -278,6 +349,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
|
||||
|
||||
// StartOrderSync starts background order sync task for Binance
|
||||
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
// Run first sync immediately
|
||||
go func() {
|
||||
logger.Infof("🔄 Running initial Binance order sync...")
|
||||
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Then run periodically
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
|
||||
461
trader/binance_order_sync_test.go
Normal file
461
trader/binance_order_sync_test.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func skipIfNoLiveTest(t *testing.T) {
|
||||
if os.Getenv("BINANCE_LIVE_TEST") != "1" {
|
||||
t.Skip("Skipping live test. Set BINANCE_LIVE_TEST=1 to run")
|
||||
}
|
||||
}
|
||||
|
||||
func getBinanceTestCredentials(t *testing.T) (string, string) {
|
||||
apiKey := os.Getenv("BINANCE_TEST_API_KEY")
|
||||
secretKey := os.Getenv("BINANCE_TEST_SECRET_KEY")
|
||||
if apiKey == "" || secretKey == "" {
|
||||
t.Skip("Skipping test. Set BINANCE_TEST_API_KEY and BINANCE_TEST_SECRET_KEY env vars")
|
||||
}
|
||||
return apiKey, secretKey
|
||||
}
|
||||
|
||||
func createBinanceTestTrader(t *testing.T) *FuturesTrader {
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestBinanceConnection tests basic API connectivity
|
||||
func TestBinanceConnection(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get balance: %v", err)
|
||||
}
|
||||
t.Logf("✅ Connection OK - Balance: %v", balance)
|
||||
}
|
||||
|
||||
// TestBinanceGetPositions tests position retrieval
|
||||
func TestBinanceGetPositions(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📊 Found %d positions with non-zero amount:", len(positions))
|
||||
for i, pos := range positions {
|
||||
symbol := pos["symbol"].(string)
|
||||
side := pos["side"].(string)
|
||||
posAmt := pos["positionAmt"].(float64)
|
||||
entryPrice := pos["entryPrice"].(float64)
|
||||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||||
|
||||
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f pnl=%.4f",
|
||||
i+1, symbol, side, posAmt, entryPrice, unrealizedPnl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetCommissionSymbols tests COMMISSION income detection
|
||||
func TestBinanceGetCommissionSymbols(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// Test different time ranges
|
||||
timeRanges := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
}{
|
||||
{"1 hour", 1 * time.Hour},
|
||||
{"24 hours", 24 * time.Hour},
|
||||
{"7 days", 7 * 24 * time.Hour},
|
||||
{"30 days", 30 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tr := range timeRanges {
|
||||
startTime := time.Now().Add(-tr.duration)
|
||||
symbols, err := trader.GetCommissionSymbols(startTime)
|
||||
if err != nil {
|
||||
t.Logf("❌ %s: Failed to get commission symbols: %v", tr.name, err)
|
||||
continue
|
||||
}
|
||||
t.Logf("📋 %s: COMMISSION symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetPnLSymbols tests REALIZED_PNL income detection
|
||||
func TestBinanceGetPnLSymbols(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
timeRanges := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
}{
|
||||
{"1 hour", 1 * time.Hour},
|
||||
{"24 hours", 24 * time.Hour},
|
||||
{"7 days", 7 * 24 * time.Hour},
|
||||
{"30 days", 30 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tr := range timeRanges {
|
||||
startTime := time.Now().Add(-tr.duration)
|
||||
symbols, err := trader.GetPnLSymbols(startTime)
|
||||
if err != nil {
|
||||
t.Logf("❌ %s: Failed to get PnL symbols: %v", tr.name, err)
|
||||
continue
|
||||
}
|
||||
t.Logf("📋 %s: REALIZED_PNL symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetAllIncomeTypes tests all income types to understand data availability
|
||||
func TestBinanceGetAllIncomeTypes(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// All possible income types from Binance API
|
||||
incomeTypes := []string{
|
||||
"TRANSFER",
|
||||
"WELCOME_BONUS",
|
||||
"REALIZED_PNL",
|
||||
"FUNDING_FEE",
|
||||
"COMMISSION",
|
||||
"INSURANCE_CLEAR",
|
||||
"REFERRAL_KICKBACK",
|
||||
"COMMISSION_REBATE",
|
||||
"API_REBATE",
|
||||
"CONTEST_REWARD",
|
||||
"CROSS_COLLATERAL_TRANSFER",
|
||||
"OPTIONS_PREMIUM_FEE",
|
||||
"OPTIONS_SETTLE_PROFIT",
|
||||
"INTERNAL_TRANSFER",
|
||||
"AUTO_EXCHANGE",
|
||||
"DELIVERED_SETTELMENT",
|
||||
"COIN_SWAP_DEPOSIT",
|
||||
"COIN_SWAP_WITHDRAW",
|
||||
"POSITION_LIMIT_INCREASE_FEE",
|
||||
}
|
||||
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
t.Logf("🔍 Checking all income types from %s:", startTime.Format(time.RFC3339))
|
||||
|
||||
for _, incomeType := range incomeTypes {
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
IncomeType(incomeType).
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", incomeType, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(incomes) > 0 {
|
||||
symbolMap := make(map[string]int)
|
||||
for _, inc := range incomes {
|
||||
if inc.Symbol != "" {
|
||||
symbolMap[inc.Symbol]++
|
||||
}
|
||||
}
|
||||
t.Logf(" ✅ %s: %d records, symbols: %v", incomeType, len(incomes), symbolMap)
|
||||
} else {
|
||||
t.Logf(" ⚪ %s: 0 records", incomeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetTradesForSymbol tests trade retrieval for specific symbols
|
||||
func TestBinanceGetTradesForSymbol(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// Common trading pairs
|
||||
symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"}
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
t.Logf("🔍 Checking trades for common symbols from %s:", startTime.Format(time.RFC3339))
|
||||
|
||||
for _, symbol := range symbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(trades) > 0 {
|
||||
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||
// Print first and last trade
|
||||
first := trades[0]
|
||||
last := trades[len(trades)-1]
|
||||
t.Logf(" First: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||
first.TradeID, first.Symbol, first.Side,
|
||||
first.Quantity, first.Price, first.RealizedPnL,
|
||||
first.Time.Format(time.RFC3339))
|
||||
if len(trades) > 1 {
|
||||
t.Logf(" Last: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||
last.TradeID, last.Symbol, last.Side,
|
||||
last.Quantity, last.Price, last.RealizedPnL,
|
||||
last.Time.Format(time.RFC3339))
|
||||
}
|
||||
} else {
|
||||
t.Logf(" ⚪ %s: 0 trades", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceTimestampFormats tests different timestamp formats
|
||||
func TestBinanceTimestampFormats(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
now := time.Now()
|
||||
nowUTC := time.Now().UTC()
|
||||
|
||||
t.Logf("🕐 Time comparison:")
|
||||
t.Logf(" time.Now(): %s (UnixMilli: %d)", now.Format(time.RFC3339), now.UnixMilli())
|
||||
t.Logf(" time.Now().UTC(): %s (UnixMilli: %d)", nowUTC.Format(time.RFC3339), nowUTC.UnixMilli())
|
||||
t.Logf(" Difference: %v", now.Sub(nowUTC))
|
||||
|
||||
// The key insight: UnixMilli() should be the SAME regardless of timezone
|
||||
if now.UnixMilli() != nowUTC.UnixMilli() {
|
||||
t.Errorf("❌ UnixMilli() differs between local and UTC! This should never happen.")
|
||||
} else {
|
||||
t.Logf(" ✅ UnixMilli() is the same (correct behavior)")
|
||||
}
|
||||
|
||||
// Test what happens when we parse a time stored in DB
|
||||
// Simulate old DB value stored in local time
|
||||
oldLocalTime := time.Date(2026, 1, 6, 18, 0, 0, 0, time.Local) // 18:00 local
|
||||
oldLocalTimeAsUTC := time.Date(2026, 1, 6, 18, 0, 0, 0, time.UTC) // Same numbers but UTC
|
||||
|
||||
t.Logf("\n🔍 Timezone mismatch scenario:")
|
||||
t.Logf(" Old DB time (local): %s (UnixMilli: %d)", oldLocalTime.Format(time.RFC3339), oldLocalTime.UnixMilli())
|
||||
t.Logf(" Same time parsed as UTC: %s (UnixMilli: %d)", oldLocalTimeAsUTC.Format(time.RFC3339), oldLocalTimeAsUTC.UnixMilli())
|
||||
t.Logf(" Difference: %v", time.Duration(oldLocalTimeAsUTC.UnixMilli()-oldLocalTime.UnixMilli())*time.Millisecond)
|
||||
|
||||
// If server is in +8 timezone, the difference should be 8 hours
|
||||
_, offset := now.Zone()
|
||||
t.Logf(" Local timezone offset: %d seconds (%d hours)", offset, offset/3600)
|
||||
}
|
||||
|
||||
// TestBinanceFullSyncSimulation simulates the full sync process
|
||||
func TestBinanceFullSyncSimulation(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
t.Logf("🔄 Simulating full sync process...")
|
||||
|
||||
// Step 1: Determine lastSyncTime (simulating first run)
|
||||
lastSyncTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
t.Logf("\n📅 Step 1: lastSyncTime = %s", lastSyncTime.Format(time.RFC3339))
|
||||
|
||||
// Step 2: Detect symbols using all methods
|
||||
symbolMap := make(map[string]bool)
|
||||
|
||||
// Method 1: COMMISSION
|
||||
commissionSymbols, err := trader.GetCommissionSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ COMMISSION failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" 📋 COMMISSION symbols: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Positions
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetPositions failed: %v", err)
|
||||
} else {
|
||||
var posSymbols []string
|
||||
for _, pos := range positions {
|
||||
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
|
||||
posSymbols = append(posSymbols, symbol)
|
||||
symbolMap[symbol] = true
|
||||
}
|
||||
}
|
||||
t.Logf(" 📋 Position symbols: %d - %v", len(posSymbols), posSymbols)
|
||||
}
|
||||
|
||||
// Method 3: REALIZED_PNL (fallback)
|
||||
pnlSymbols, err := trader.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ REALIZED_PNL failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" 📋 REALIZED_PNL symbols: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all symbols
|
||||
var allSymbols []string
|
||||
for s := range symbolMap {
|
||||
allSymbols = append(allSymbols, s)
|
||||
}
|
||||
t.Logf("\n📊 Step 2: Total unique symbols to sync: %d - %v", len(allSymbols), allSymbols)
|
||||
|
||||
if len(allSymbols) == 0 {
|
||||
t.Logf("❌ No symbols found! This is the bug - nothing to sync")
|
||||
t.Logf("\n🔍 Investigating why no symbols found...")
|
||||
|
||||
// Try to query all income (without type filter) to see if there's ANY activity
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(lastSyncTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Logf(" Failed to get all income: %v", err)
|
||||
} else {
|
||||
t.Logf(" All income records (no type filter): %d", len(incomes))
|
||||
typeCount := make(map[string]int)
|
||||
for _, inc := range incomes {
|
||||
typeCount[inc.IncomeType]++
|
||||
}
|
||||
t.Logf(" Income types breakdown: %v", typeCount)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Query trades for each symbol
|
||||
t.Logf("\n📥 Step 3: Querying trades for each symbol...")
|
||||
totalTrades := 0
|
||||
for _, symbol := range allSymbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, lastSyncTime, 500)
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
totalTrades += len(trades)
|
||||
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||
|
||||
// Print sample trades
|
||||
for i, trade := range trades {
|
||||
if i >= 3 {
|
||||
t.Logf(" ... and %d more trades", len(trades)-3)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f fee=%.6f time=%s",
|
||||
i+1, trade.TradeID, trade.Symbol, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee,
|
||||
trade.Time.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n✅ Sync simulation complete: %d total trades found across %d symbols",
|
||||
totalTrades, len(allSymbols))
|
||||
}
|
||||
|
||||
// TestBinanceTradeIDRange tests trade ID ranges to understand the data
|
||||
func TestBinanceTradeIDRange(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// First find symbols with trades
|
||||
startTime := time.Now().Add(-30 * 24 * time.Hour)
|
||||
commissionSymbols, _ := trader.GetCommissionSymbols(startTime)
|
||||
pnlSymbols, _ := trader.GetPnLSymbols(startTime)
|
||||
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
if len(symbolMap) == 0 {
|
||||
t.Log("No symbols with activity found")
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("🔍 Checking trade ID ranges for symbols with activity:")
|
||||
|
||||
for symbol := range symbolMap {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||
if err != nil || len(trades) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var minID, maxID int64 = 1<<62, 0
|
||||
for _, trade := range trades {
|
||||
var id int64
|
||||
fmt.Sscanf(trade.TradeID, "%d", &id)
|
||||
if id < minID {
|
||||
minID = id
|
||||
}
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf(" %s: %d trades, ID range [%d - %d]", symbol, len(trades), minID, maxID)
|
||||
|
||||
// Check if any ID exceeds PostgreSQL INTEGER max
|
||||
if maxID > 2147483647 {
|
||||
t.Logf(" ⚠️ Max trade ID %d exceeds PostgreSQL INTEGER max (2147483647)", maxID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceIncomeAPIDirectCall makes direct API call to understand response
|
||||
func TestBinanceIncomeAPIDirectCall(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
t.Logf("🔍 Direct income API call from %s:", startTime.Format(time.RFC3339))
|
||||
t.Logf(" StartTime UnixMilli: %d", startTime.UnixMilli())
|
||||
|
||||
// Call without income type filter to get ALL income
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(1000).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get income: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Total income records: %d", len(incomes))
|
||||
|
||||
// Group by type and symbol
|
||||
typeSymbolCount := make(map[string]map[string]int)
|
||||
for _, inc := range incomes {
|
||||
if typeSymbolCount[inc.IncomeType] == nil {
|
||||
typeSymbolCount[inc.IncomeType] = make(map[string]int)
|
||||
}
|
||||
typeSymbolCount[inc.IncomeType][inc.Symbol]++
|
||||
}
|
||||
|
||||
for incType, symbols := range typeSymbolCount {
|
||||
t.Logf(" %s:", incType)
|
||||
for symbol, count := range symbols {
|
||||
if symbol == "" {
|
||||
symbol = "(no symbol)"
|
||||
}
|
||||
t.Logf(" %s: %d records", symbol, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Print sample records
|
||||
if len(incomes) > 0 {
|
||||
t.Logf("\n📝 Sample income records (first 5):")
|
||||
for i, inc := range incomes {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] Type=%s Symbol=%s Amount=%s Time=%s",
|
||||
i+1, inc.IncomeType, inc.Symbol, inc.Income,
|
||||
time.UnixMilli(inc.Time).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
218
trader/binance_sync_e2e_test.go
Normal file
218
trader/binance_sync_e2e_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/store"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBinanceSyncE2E tests the complete sync flow end-to-end
|
||||
func TestBinanceSyncE2E(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
// Create test database using full store initialization (includes table creation)
|
||||
testDBPath := "/tmp/test_binance_sync.db"
|
||||
os.Remove(testDBPath) // Clean up previous test
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
|
||||
// Create trader
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
// Test parameters
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
t.Logf("🧪 Running end-to-end sync test...")
|
||||
t.Logf(" DB Path: %s", testDBPath)
|
||||
|
||||
// Run sync
|
||||
t.Logf("\n📥 Running SyncOrdersFromBinance...")
|
||||
startTime := time.Now()
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Sync completed in %v", elapsed)
|
||||
|
||||
// Check results in database
|
||||
orderStore := st.Order()
|
||||
|
||||
// Count orders
|
||||
var orderCount int64
|
||||
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&orderCount)
|
||||
t.Logf("\n📊 Results:")
|
||||
t.Logf(" Orders in DB: %d", orderCount)
|
||||
|
||||
// Count fills
|
||||
var fillCount int64
|
||||
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||
t.Logf(" Fills in DB: %d", fillCount)
|
||||
|
||||
// Get symbols
|
||||
var symbols []string
|
||||
db.Model(&store.TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ?", exchangeID).
|
||||
Pluck("symbol", &symbols)
|
||||
t.Logf(" Unique symbols: %d - %v", len(symbols), symbols)
|
||||
|
||||
// Check max trade IDs (test the fix)
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetMaxTradeIDsByExchange error: %v", err)
|
||||
} else {
|
||||
t.Logf(" Max trade IDs per symbol:")
|
||||
for symbol, maxID := range maxTradeIDs {
|
||||
if maxID > 2147483647 {
|
||||
t.Logf(" %s: %d (⚠️ exceeds PostgreSQL INTEGER max)", symbol, maxID)
|
||||
} else {
|
||||
t.Logf(" %s: %d", symbol, maxID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sample some orders
|
||||
var sampleOrders []store.TraderOrder
|
||||
db.Where("exchange_id = ?", exchangeID).Limit(5).Find(&sampleOrders)
|
||||
if len(sampleOrders) > 0 {
|
||||
t.Logf("\n📝 Sample orders:")
|
||||
for i, order := range sampleOrders {
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
|
||||
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
|
||||
order.Quantity, order.Price, order.OrderAction,
|
||||
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// Test incremental sync - run again, should find no new trades
|
||||
t.Logf("\n🔄 Running incremental sync (should skip existing trades)...")
|
||||
startTime = time.Now()
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
elapsed = time.Since(startTime)
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Incremental sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Incremental sync completed in %v", elapsed)
|
||||
|
||||
// Check counts again - should be the same
|
||||
var newOrderCount int64
|
||||
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&newOrderCount)
|
||||
t.Logf(" Orders after incremental sync: %d (was %d)", newOrderCount, orderCount)
|
||||
|
||||
if newOrderCount != orderCount {
|
||||
t.Logf(" ⚠️ Order count changed - possible duplicate detection issue")
|
||||
} else {
|
||||
t.Logf(" ✅ No duplicates - incremental sync working correctly")
|
||||
}
|
||||
|
||||
// Test GetLastFillTimeByExchange
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||
} else {
|
||||
lastFillTime := time.UnixMilli(lastFillTimeMs)
|
||||
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
// Check if it would be in the future (the bug we fixed)
|
||||
now := time.Now().UTC()
|
||||
if lastFillTime.After(now) {
|
||||
t.Logf(" ❌ BUG: Last fill time is in the future! (now: %s)", now.Format(time.RFC3339))
|
||||
} else {
|
||||
t.Logf(" ✅ Last fill time is in the past (correct)")
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
os.Remove(testDBPath)
|
||||
t.Logf("\n✅ E2E test completed successfully!")
|
||||
}
|
||||
|
||||
// TestBinanceSyncWithExistingData tests sync behavior with pre-existing data
|
||||
func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
testDBPath := "/tmp/test_binance_sync_existing.db"
|
||||
os.Remove(testDBPath)
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
orderStore := st.Order()
|
||||
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
// Insert a fake "old" fill with LOCAL time (simulating the bug scenario)
|
||||
// This tests that our timezone fix works
|
||||
localTime := time.Now().Add(8 * time.Hour) // Simulate +8 timezone stored as if it were UTC
|
||||
fakeFill := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangeOrderID: "fake-old-order",
|
||||
ExchangeTradeID: "fake-old-trade",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "BUY",
|
||||
Price: 50000,
|
||||
Quantity: 0.001,
|
||||
QuoteQuantity: 50,
|
||||
CreatedAt: localTime.UnixMilli(), // This time is "in the future" if interpreted as UTC
|
||||
}
|
||||
if err := orderStore.CreateFill(fakeFill); err != nil {
|
||||
t.Fatalf("Failed to create fake fill: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("🧪 Testing sync with existing 'future' data...")
|
||||
t.Logf(" Fake fill time: %s", localTime.Format(time.RFC3339))
|
||||
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Check GetLastFillTimeByExchange
|
||||
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
|
||||
|
||||
if lastFillTime2.After(time.Now().UTC()) {
|
||||
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
|
||||
}
|
||||
|
||||
// Run sync - it should detect the future time and fall back
|
||||
t.Logf("\n📥 Running sync (should detect future time and fall back)...")
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Sync completed")
|
||||
|
||||
// Check that trades were actually synced despite the bad data
|
||||
var fillCount int64
|
||||
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||
t.Logf(" Total fills in DB: %d (includes 1 fake)", fillCount)
|
||||
|
||||
if fillCount > 1 {
|
||||
t.Logf(" ✅ Real trades were synced despite 'future' data!")
|
||||
} else {
|
||||
t.Logf(" ❌ No real trades synced - the bug might still exist")
|
||||
}
|
||||
|
||||
os.Remove(testDBPath)
|
||||
}
|
||||
511
trader/binance_sync_verify_test.go
Normal file
511
trader/binance_sync_verify_test.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func repeatStr(s string, n int) string {
|
||||
return strings.Repeat(s, n)
|
||||
}
|
||||
|
||||
// TestBinanceSyncVerification verifies synced data matches exchange data exactly
|
||||
func TestBinanceSyncVerification(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
// Create test database
|
||||
testDBPath := "/tmp/test_binance_verify.db"
|
||||
os.Remove(testDBPath)
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
// Step 1: Run sync
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 1: Running order sync...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all trades from exchange for verification
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 2: Fetching trades from exchange for verification...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
|
||||
// Get symbols from DB
|
||||
var symbols []string
|
||||
db.Model(&store.TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ?", exchangeID).
|
||||
Pluck("symbol", &symbols)
|
||||
|
||||
t.Logf("Symbols to verify: %v", symbols)
|
||||
|
||||
// Fetch all trades from exchange
|
||||
type ExchangeTrade struct {
|
||||
TradeID string
|
||||
Symbol string
|
||||
Side string
|
||||
Price float64
|
||||
Quantity float64
|
||||
Fee float64
|
||||
RealizedPnL float64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
var exchangeTrades []ExchangeTrade
|
||||
for _, symbol := range symbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 1000)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
for _, trade := range trades {
|
||||
exchangeTrades = append(exchangeTrades, ExchangeTrade{
|
||||
TradeID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
Fee: trade.Fee,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
Time: trade.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Total trades from exchange: %d", len(exchangeTrades))
|
||||
|
||||
// Step 3: Get all fills from DB
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 3: Comparing with local database...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var dbFills []store.TraderFill
|
||||
db.Where("exchange_id = ?", exchangeID).Find(&dbFills)
|
||||
|
||||
t.Logf("Total fills in DB: %d", len(dbFills))
|
||||
|
||||
// Create maps for comparison
|
||||
exchangeTradeMap := make(map[string]ExchangeTrade)
|
||||
for _, t := range exchangeTrades {
|
||||
exchangeTradeMap[t.TradeID] = t
|
||||
}
|
||||
|
||||
dbFillMap := make(map[string]store.TraderFill)
|
||||
for _, f := range dbFills {
|
||||
dbFillMap[f.ExchangeTradeID] = f
|
||||
}
|
||||
|
||||
// Step 4: Check for missing trades
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 4: Checking for MISSING trades (in exchange but not in DB)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var missingTrades []ExchangeTrade
|
||||
for tradeID, trade := range exchangeTradeMap {
|
||||
if _, exists := dbFillMap[tradeID]; !exists {
|
||||
missingTrades = append(missingTrades, trade)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingTrades) > 0 {
|
||||
t.Logf("❌ MISSING %d trades:", len(missingTrades))
|
||||
for i, trade := range missingTrades {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(missingTrades)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s %s qty=%.6f price=%.4f time=%s",
|
||||
trade.TradeID, trade.Symbol, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.Time.Format(time.RFC3339))
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ No missing trades")
|
||||
}
|
||||
|
||||
// Step 5: Check for extra/duplicate trades
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 5: Checking for EXTRA trades (in DB but not in exchange)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var extraTrades []store.TraderFill
|
||||
for tradeID, fill := range dbFillMap {
|
||||
if _, exists := exchangeTradeMap[tradeID]; !exists {
|
||||
extraTrades = append(extraTrades, fill)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraTrades) > 0 {
|
||||
t.Logf("❌ EXTRA %d trades in DB:", len(extraTrades))
|
||||
for i, fill := range extraTrades {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(extraTrades)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s %s qty=%.6f price=%.4f",
|
||||
fill.ExchangeTradeID, fill.Symbol, fill.Side,
|
||||
fill.Quantity, fill.Price)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ No extra/duplicate trades")
|
||||
}
|
||||
|
||||
// Step 6: Check for data accuracy
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 6: Verifying data accuracy (price, qty, fee, pnl)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
type DataMismatch struct {
|
||||
TradeID string
|
||||
Field string
|
||||
DB float64
|
||||
Exchange float64
|
||||
}
|
||||
|
||||
var mismatches []DataMismatch
|
||||
for tradeID, exchangeTrade := range exchangeTradeMap {
|
||||
dbFill, exists := dbFillMap[tradeID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare price
|
||||
if !floatEqual(dbFill.Price, exchangeTrade.Price, 0.0001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Price",
|
||||
DB: dbFill.Price, Exchange: exchangeTrade.Price,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare quantity
|
||||
if !floatEqual(dbFill.Quantity, exchangeTrade.Quantity, 0.000001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Quantity",
|
||||
DB: dbFill.Quantity, Exchange: exchangeTrade.Quantity,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare fee
|
||||
if !floatEqual(dbFill.Commission, exchangeTrade.Fee, 0.000001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Fee",
|
||||
DB: dbFill.Commission, Exchange: exchangeTrade.Fee,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare realized PnL
|
||||
if !floatEqual(dbFill.RealizedPnL, exchangeTrade.RealizedPnL, 0.01) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "RealizedPnL",
|
||||
DB: dbFill.RealizedPnL, Exchange: exchangeTrade.RealizedPnL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
t.Logf("❌ DATA MISMATCHES: %d", len(mismatches))
|
||||
for i, m := range mismatches {
|
||||
if i >= 20 {
|
||||
t.Logf(" ... and %d more", len(mismatches)-20)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s: DB=%.6f, Exchange=%.6f",
|
||||
m.TradeID, m.Field, m.DB, m.Exchange)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ All data matches exactly")
|
||||
}
|
||||
|
||||
// Step 7: Summary by symbol
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 7: Summary by symbol...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
type SymbolSummary struct {
|
||||
Symbol string
|
||||
ExchangeCount int
|
||||
DBCount int
|
||||
TotalQty float64
|
||||
TotalFee float64
|
||||
TotalPnL float64
|
||||
ExchangeTotalQty float64
|
||||
ExchangeTotalFee float64
|
||||
ExchangeTotalPnL float64
|
||||
}
|
||||
|
||||
summaryMap := make(map[string]*SymbolSummary)
|
||||
|
||||
for _, trade := range exchangeTrades {
|
||||
if summaryMap[trade.Symbol] == nil {
|
||||
summaryMap[trade.Symbol] = &SymbolSummary{Symbol: trade.Symbol}
|
||||
}
|
||||
s := summaryMap[trade.Symbol]
|
||||
s.ExchangeCount++
|
||||
s.ExchangeTotalQty += trade.Quantity
|
||||
s.ExchangeTotalFee += trade.Fee
|
||||
s.ExchangeTotalPnL += trade.RealizedPnL
|
||||
}
|
||||
|
||||
for _, fill := range dbFills {
|
||||
if summaryMap[fill.Symbol] == nil {
|
||||
summaryMap[fill.Symbol] = &SymbolSummary{Symbol: fill.Symbol}
|
||||
}
|
||||
s := summaryMap[fill.Symbol]
|
||||
s.DBCount++
|
||||
s.TotalQty += fill.Quantity
|
||||
s.TotalFee += fill.Commission
|
||||
s.TotalPnL += fill.RealizedPnL
|
||||
}
|
||||
|
||||
t.Logf("\n%-15s %10s %10s %15s %15s %15s", "Symbol", "Exchange", "DB", "Fee(Exc/DB)", "PnL(Exc/DB)", "Match")
|
||||
t.Logf("%s", repeatStr("-", 80))
|
||||
|
||||
for _, s := range summaryMap {
|
||||
countMatch := s.ExchangeCount == s.DBCount
|
||||
feeMatch := floatEqual(s.ExchangeTotalFee, s.TotalFee, 0.01)
|
||||
pnlMatch := floatEqual(s.ExchangeTotalPnL, s.TotalPnL, 0.01)
|
||||
|
||||
matchStr := "✅"
|
||||
if !countMatch || !feeMatch || !pnlMatch {
|
||||
matchStr = "❌"
|
||||
}
|
||||
|
||||
t.Logf("%-15s %10d %10d %7.2f/%-7.2f %7.2f/%-7.2f %s",
|
||||
s.Symbol, s.ExchangeCount, s.DBCount,
|
||||
s.ExchangeTotalFee, s.TotalFee,
|
||||
s.ExchangeTotalPnL, s.TotalPnL,
|
||||
matchStr)
|
||||
}
|
||||
|
||||
// Step 8: Position verification
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 8: Verifying position calculations...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
// Get positions from DB
|
||||
var dbPositions []store.TraderPosition
|
||||
db.Where("exchange_id = ? AND status = ?", exchangeID, "closed").Find(&dbPositions)
|
||||
|
||||
t.Logf("Closed positions in DB: %d", len(dbPositions))
|
||||
|
||||
// Get current positions from exchange
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get exchange positions: %v", err)
|
||||
} else {
|
||||
t.Logf("Active positions on exchange: %d", len(exchangePositions))
|
||||
for _, pos := range exchangePositions {
|
||||
t.Logf(" - %s %s qty=%.6f entry=%.4f pnl=%.4f",
|
||||
pos["symbol"], pos["side"],
|
||||
pos["positionAmt"], pos["entryPrice"], pos["unRealizedProfit"])
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total PnL from trades
|
||||
var totalRealizedPnL float64
|
||||
var totalFees float64
|
||||
for _, fill := range dbFills {
|
||||
totalRealizedPnL += fill.RealizedPnL
|
||||
totalFees += fill.Commission
|
||||
}
|
||||
|
||||
t.Logf("\n📊 PnL Summary from DB:")
|
||||
t.Logf(" Total Realized PnL: %.4f USDT", totalRealizedPnL)
|
||||
t.Logf(" Total Fees: %.4f USDT", totalFees)
|
||||
t.Logf(" Net PnL: %.4f USDT", totalRealizedPnL-totalFees)
|
||||
|
||||
// Calculate from exchange
|
||||
var exchangeTotalPnL float64
|
||||
var exchangeTotalFees float64
|
||||
for _, trade := range exchangeTrades {
|
||||
exchangeTotalPnL += trade.RealizedPnL
|
||||
exchangeTotalFees += trade.Fee
|
||||
}
|
||||
|
||||
t.Logf("\n📊 PnL Summary from Exchange:")
|
||||
t.Logf(" Total Realized PnL: %.4f USDT", exchangeTotalPnL)
|
||||
t.Logf(" Total Fees: %.4f USDT", exchangeTotalFees)
|
||||
t.Logf(" Net PnL: %.4f USDT", exchangeTotalPnL-exchangeTotalFees)
|
||||
|
||||
// Compare
|
||||
pnlMatch := floatEqual(totalRealizedPnL, exchangeTotalPnL, 0.01)
|
||||
feeMatch := floatEqual(totalFees, exchangeTotalFees, 0.01)
|
||||
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("FINAL VERIFICATION RESULT")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
allPassed := true
|
||||
|
||||
if len(missingTrades) > 0 {
|
||||
t.Logf("❌ Missing trades: %d", len(missingTrades))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ No missing trades")
|
||||
}
|
||||
|
||||
if len(extraTrades) > 0 {
|
||||
t.Logf("❌ Extra/duplicate trades: %d", len(extraTrades))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ No extra/duplicate trades")
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
t.Logf("❌ Data mismatches: %d", len(mismatches))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ All data accurate")
|
||||
}
|
||||
|
||||
if !pnlMatch {
|
||||
t.Logf("❌ PnL mismatch: DB=%.4f, Exchange=%.4f", totalRealizedPnL, exchangeTotalPnL)
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ PnL matches")
|
||||
}
|
||||
|
||||
if !feeMatch {
|
||||
t.Logf("❌ Fee mismatch: DB=%.4f, Exchange=%.4f", totalFees, exchangeTotalFees)
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ Fees match")
|
||||
}
|
||||
|
||||
if allPassed {
|
||||
t.Logf("\n🎉 ALL VERIFICATIONS PASSED!")
|
||||
} else {
|
||||
t.Logf("\n⚠️ SOME VERIFICATIONS FAILED - CHECK ABOVE FOR DETAILS")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
os.Remove(testDBPath)
|
||||
}
|
||||
|
||||
// floatEqual compares two floats with tolerance
|
||||
func floatEqual(a, b, tolerance float64) bool {
|
||||
return math.Abs(a-b) <= tolerance
|
||||
}
|
||||
|
||||
// TestBinanceDetailedTradeComparison shows detailed trade-by-trade comparison
|
||||
func TestBinanceDetailedTradeComparison(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
startTime := time.Now().UTC().Add(-24 * time.Hour)
|
||||
|
||||
// Get all income (to find symbols with activity)
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get income: %v", err)
|
||||
}
|
||||
|
||||
// Find unique symbols
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, inc := range incomes {
|
||||
if inc.Symbol != "" {
|
||||
symbolMap[inc.Symbol] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbolMap) == 0 {
|
||||
t.Log("No trading activity in the last 24 hours")
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
t.Logf("DETAILED TRADE REPORT (Last 24 hours)")
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
|
||||
var grandTotalQty float64
|
||||
var grandTotalFee float64
|
||||
var grandTotalPnL float64
|
||||
|
||||
for symbol := range symbolMap {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 500)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(trades) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
})
|
||||
|
||||
t.Logf("\n%s", repeatStr("-", 100))
|
||||
t.Logf("📊 %s - %d trades", symbol, len(trades))
|
||||
t.Logf("%s", repeatStr("-", 100))
|
||||
t.Logf("%-15s %-6s %12s %12s %12s %12s %20s",
|
||||
"TradeID", "Side", "Quantity", "Price", "Fee", "PnL", "Time")
|
||||
|
||||
var totalQty, totalFee, totalPnL float64
|
||||
var buyQty, sellQty float64
|
||||
|
||||
for _, trade := range trades {
|
||||
t.Logf("%-15s %-6s %12.6f %12.4f %12.6f %12.4f %20s",
|
||||
trade.TradeID, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time.Format("2006-01-02 15:04:05"))
|
||||
|
||||
totalQty += trade.Quantity
|
||||
totalFee += trade.Fee
|
||||
totalPnL += trade.RealizedPnL
|
||||
|
||||
if trade.Side == "BUY" {
|
||||
buyQty += trade.Quantity
|
||||
} else {
|
||||
sellQty += trade.Quantity
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("%s", repeatStr("-", 100))
|
||||
t.Logf("SUBTOTAL: %d trades, Buy=%.6f, Sell=%.6f, Fee=%.6f, PnL=%.4f",
|
||||
len(trades), buyQty, sellQty, totalFee, totalPnL)
|
||||
|
||||
grandTotalQty += totalQty
|
||||
grandTotalFee += totalFee
|
||||
grandTotalPnL += totalPnL
|
||||
}
|
||||
|
||||
t.Logf("\n%s", repeatStr("=", 100))
|
||||
t.Logf("GRAND TOTAL")
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
t.Logf("Total Fee: %.6f USDT", grandTotalFee)
|
||||
t.Logf("Total PnL: %.4f USDT", grandTotalPnL)
|
||||
t.Logf("Net PnL: %.4f USDT", grandTotalPnL-grandTotalFee)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
FillQty: fillQty,
|
||||
Fee: -fee, // Bitget returns negative fee
|
||||
FeeAsset: fill.FeeCcy,
|
||||
ExecTime: time.UnixMilli(cTime),
|
||||
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||
ProfitLoss: profit,
|
||||
OrderType: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
@@ -146,7 +146,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -174,7 +174,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -191,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
FilledQuantity: trade.FillQty,
|
||||
AvgFillPrice: trade.FillPrice,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.ExecTime,
|
||||
CreatedAt: trade.ExecTime,
|
||||
UpdatedAt: trade.ExecTime,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -202,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use UTC time in milliseconds
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -219,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
CommissionAsset: trade.FeeAsset,
|
||||
RealizedPnL: trade.ProfitLoss,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.ExecTime,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -231,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
trade.ExecTime, trade.TradeID,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -1069,8 +1069,8 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
|
||||
|
||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||
record.EntryTime = time.UnixMilli(cTime)
|
||||
record.ExitTime = time.UnixMilli(uTime)
|
||||
record.EntryTime = time.UnixMilli(cTime).UTC()
|
||||
record.ExitTime = time.UnixMilli(uTime).UTC()
|
||||
|
||||
record.CloseType = "unknown"
|
||||
records = append(records, record)
|
||||
@@ -1096,3 +1096,243 @@ func genBitgetClientOid() string {
|
||||
rand := time.Now().Nanosecond() % 100000
|
||||
return fmt.Sprintf("nofx%d%05d", timestamp, rand)
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", bitgetPendingPath, params)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
TradeSide string `json:"tradeSide"` // open/close
|
||||
PosSide string `json:"posSide"` // long/short
|
||||
OrderType string `json:"orderType"` // limit/market
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders.EntrustedList {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
// Convert side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrderType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
||||
planParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
|
||||
}
|
||||
if err == nil && planData != nil {
|
||||
var planOrders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||
for _, order := range planOrders.EntrustedList {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
// Map Bitget plan type to order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.PlanType == "profit_plan" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Format quantity
|
||||
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
|
||||
|
||||
// Determine side
|
||||
side := "buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"marginMode": "crossed",
|
||||
"marginCoin": "USDT",
|
||||
"side": side,
|
||||
"orderType": "limit",
|
||||
"size": qtyStr,
|
||||
"price": fmt.Sprintf("%.8f", req.Price),
|
||||
"force": "GTC", // Good Till Cancel
|
||||
"clientOid": genBitgetClientOid(),
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = "YES"
|
||||
}
|
||||
|
||||
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
|
||||
|
||||
data, err := t.doRequest("POST", bitgetOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var order struct {
|
||||
OrderId string `json:"orderId"`
|
||||
ClientOid string `json:"clientOid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (t *BybitTrader) parseTradesResult(list []map[string]interface{}) ([]BybitT
|
||||
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
|
||||
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
|
||||
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
|
||||
execTime := time.UnixMilli(execTimeMs)
|
||||
execTime := time.UnixMilli(execTimeMs).UTC()
|
||||
|
||||
// Determine order action based on side and closedSize
|
||||
// If closedSize > 0, it's a close trade
|
||||
@@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -223,7 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -240,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
FilledQuantity: trade.ExecQty,
|
||||
AvgFillPrice: trade.ExecPrice,
|
||||
Commission: trade.ExecFee,
|
||||
FilledAt: trade.ExecTime,
|
||||
CreatedAt: trade.ExecTime,
|
||||
UpdatedAt: trade.ExecTime,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -251,7 +252,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use UTC time
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -268,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.ClosedPnL,
|
||||
IsMaker: trade.IsMaker,
|
||||
CreatedAt: trade.ExecTime,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -280,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
||||
trade.ExecTime, trade.ExecID,
|
||||
execTimeMs, trade.ExecID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
||||
} else {
|
||||
|
||||
@@ -1032,8 +1032,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
RealizedPnL: closedPnL,
|
||||
Fee: fee,
|
||||
Leverage: int(leverage),
|
||||
EntryTime: time.UnixMilli(createdTime),
|
||||
ExitTime: time.UnixMilli(updatedTime),
|
||||
EntryTime: time.UnixMilli(createdTime).UTC(),
|
||||
ExitTime: time.UnixMilli(updatedTime).UTC(),
|
||||
OrderID: orderId,
|
||||
CloseType: "unknown", // Bybit doesn't provide close type directly
|
||||
ExchangeID: orderId, // Use orderId as exchange ID
|
||||
@@ -1044,3 +1044,220 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// Get conditional orders (stop-loss, take-profit)
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderFilter": "StopOrder",
|
||||
}
|
||||
|
||||
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
if resp.RetCode == 0 {
|
||||
resultData, ok := resp.Result.(map[string]interface{})
|
||||
if ok {
|
||||
list, _ := resultData["list"].([]interface{})
|
||||
for _, item := range list {
|
||||
order, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
orderId, _ := order["orderId"].(string)
|
||||
sym, _ := order["symbol"].(string)
|
||||
side, _ := order["side"].(string)
|
||||
orderType, _ := order["orderType"].(string)
|
||||
stopOrderType, _ := order["stopOrderType"].(string)
|
||||
triggerPrice, _ := order["triggerPrice"].(string)
|
||||
qty, _ := order["qty"].(string)
|
||||
|
||||
price, _ := strconv.ParseFloat(triggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(qty, 64)
|
||||
|
||||
// Determine type based on stopOrderType
|
||||
displayType := orderType
|
||||
if stopOrderType != "" {
|
||||
displayType = stopOrderType
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: orderId,
|
||||
Symbol: sym,
|
||||
Side: side,
|
||||
PositionSide: "", // Bybit doesn't use positionSide for UTA
|
||||
Type: displayType,
|
||||
Price: 0,
|
||||
StopPrice: price,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price
|
||||
priceStr := fmt.Sprintf("%.8f", req.Price)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side
|
||||
side := "Buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "Sell"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": req.Symbol,
|
||||
"side": side,
|
||||
"orderType": "Limit",
|
||||
"qty": qtyStr,
|
||||
"price": priceStr,
|
||||
"timeInForce": "GTC", // Good Till Cancel
|
||||
"positionIdx": 0, // One-way position mode
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Parse result
|
||||
orderID := ""
|
||||
if result.RetCode == 0 {
|
||||
if resultData, ok := result.Result.(map[string]interface{}); ok {
|
||||
if id, ok := resultData["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 25
|
||||
}
|
||||
|
||||
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
|
||||
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result struct {
|
||||
S string `json:"s"` // symbol
|
||||
B [][]string `json:"b"` // bids [[price, size], ...]
|
||||
A [][]string `json:"a"` // asks [[price, size], ...]
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Result.B {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Result.A {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func runStandardTests(t *testing.T, exchangeName string) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, trade.Side, trade.Action,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "open_long",
|
||||
0.1, 3500+float64(i*10), 0.5, 0,
|
||||
time.Now().Add(time.Duration(i*2)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "close_long",
|
||||
0.1, 3600+float64(i*10), 0.5, 10,
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "open_long",
|
||||
0.01, 50000, 1.0, 0,
|
||||
time.Now(),
|
||||
time.Now().UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "close_long",
|
||||
0.00999999, 51000, 1.0, 10,
|
||||
time.Now().Add(time.Second),
|
||||
time.Now().Add(time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
196
trader/grid_regime.go
Normal file
196
trader/grid_regime.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Task 6: Regime Level Classification
|
||||
// ============================================================================
|
||||
|
||||
// classifyRegimeLevel determines the regime level based on market indicators
|
||||
// bollingerWidth: Bollinger band width as percentage
|
||||
// atr14Pct: ATR14 as percentage of current price
|
||||
func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel {
|
||||
// Narrow: Bollinger < 2%, ATR < 1%
|
||||
if bollingerWidth < 2.0 && atr14Pct < 1.0 {
|
||||
return market.RegimeLevelNarrow
|
||||
}
|
||||
|
||||
// Standard: Bollinger 2-3%, ATR 1-2%
|
||||
if bollingerWidth <= 3.0 && atr14Pct <= 2.0 {
|
||||
return market.RegimeLevelStandard
|
||||
}
|
||||
|
||||
// Wide: Bollinger 3-4%, ATR 2-3%
|
||||
if bollingerWidth <= 4.0 && atr14Pct <= 3.0 {
|
||||
return market.RegimeLevelWide
|
||||
}
|
||||
|
||||
// Volatile: Bollinger > 4%, ATR > 3%
|
||||
return market.RegimeLevelVolatile
|
||||
}
|
||||
|
||||
// getRegimeLeverageLimit returns the effective leverage limit for a regime level
|
||||
func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridConfigModel) int {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimeLeverage > 0 {
|
||||
return config.NarrowRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimeLeverage > 0 {
|
||||
return config.StandardRegimeLeverage
|
||||
}
|
||||
return 4
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimeLeverage > 0 {
|
||||
return config.WideRegimeLeverage
|
||||
}
|
||||
return 3
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimeLeverage > 0 {
|
||||
return config.VolatileRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
default:
|
||||
return 2 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// getRegimePositionLimit returns the position limit percentage for a regime level
|
||||
func getRegimePositionLimit(level market.RegimeLevel, config *store.GridConfigModel) float64 {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimePositionPct > 0 {
|
||||
return config.NarrowRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimePositionPct > 0 {
|
||||
return config.StandardRegimePositionPct
|
||||
}
|
||||
return 70.0
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimePositionPct > 0 {
|
||||
return config.WideRegimePositionPct
|
||||
}
|
||||
return 60.0
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimePositionPct > 0 {
|
||||
return config.VolatileRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
default:
|
||||
return 40.0 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 7: Breakout Detection
|
||||
// ============================================================================
|
||||
|
||||
// detectBoxBreakout checks if price has broken out of any box level
|
||||
// Returns the highest breakout level and direction
|
||||
func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) {
|
||||
if box == nil {
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
// Check long box first (highest priority)
|
||||
if price > box.LongUpper {
|
||||
return market.BreakoutLong, "up"
|
||||
}
|
||||
if price < box.LongLower {
|
||||
return market.BreakoutLong, "down"
|
||||
}
|
||||
|
||||
// Check mid box
|
||||
if price > box.MidUpper {
|
||||
return market.BreakoutMid, "up"
|
||||
}
|
||||
if price < box.MidLower {
|
||||
return market.BreakoutMid, "down"
|
||||
}
|
||||
|
||||
// Check short box
|
||||
if price > box.ShortUpper {
|
||||
return market.BreakoutShort, "up"
|
||||
}
|
||||
if price < box.ShortLower {
|
||||
return market.BreakoutShort, "down"
|
||||
}
|
||||
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 8: Breakout Confirmation Logic
|
||||
// ============================================================================
|
||||
|
||||
const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout
|
||||
|
||||
// BreakoutState tracks the current breakout state
|
||||
type BreakoutState struct {
|
||||
Level market.BreakoutLevel
|
||||
Direction string
|
||||
ConfirmCount int
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// confirmBreakout updates breakout state and returns true if breakout is confirmed
|
||||
func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool {
|
||||
// If price returned to box, reset state
|
||||
if currentLevel == market.BreakoutNone {
|
||||
state.ConfirmCount = 0
|
||||
state.Level = market.BreakoutNone
|
||||
state.Direction = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// If same breakout continues, increment count
|
||||
if state.Level == currentLevel && state.Direction == direction {
|
||||
state.ConfirmCount++
|
||||
} else {
|
||||
// New breakout, reset count
|
||||
state.Level = currentLevel
|
||||
state.Direction = direction
|
||||
state.ConfirmCount = 1
|
||||
state.StartTime = time.Now()
|
||||
}
|
||||
|
||||
return state.ConfirmCount >= BreakoutConfirmRequired
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 9: Breakout Handler
|
||||
// ============================================================================
|
||||
|
||||
// BreakoutAction represents the action to take on breakout
|
||||
type BreakoutAction int
|
||||
|
||||
const (
|
||||
BreakoutActionNone BreakoutAction = iota
|
||||
BreakoutActionReducePosition // Short box breakout: reduce to 50%
|
||||
BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders
|
||||
BreakoutActionCloseAll // Long box breakout: pause + cancel + close all
|
||||
)
|
||||
|
||||
// getBreakoutAction returns the appropriate action for a breakout level
|
||||
func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
return BreakoutActionReducePosition
|
||||
case market.BreakoutMid:
|
||||
return BreakoutActionPauseGrid
|
||||
case market.BreakoutLong:
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
122
trader/grid_regime_test.go
Normal file
122
trader/grid_regime_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyRegimeLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bollingerWidth float64
|
||||
atr14Pct float64
|
||||
expected market.RegimeLevel
|
||||
}{
|
||||
{"narrow", 1.5, 0.8, market.RegimeLevelNarrow},
|
||||
{"standard", 2.5, 1.5, market.RegimeLevelStandard},
|
||||
{"wide", 3.5, 2.5, market.RegimeLevelWide},
|
||||
{"volatile", 5.0, 4.0, market.RegimeLevelVolatile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBoxBreakout(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
// No breakout
|
||||
level, direction := detectBoxBreakout(box)
|
||||
if level != market.BreakoutNone {
|
||||
t.Errorf("Expected no breakout, got %v", level)
|
||||
}
|
||||
|
||||
// Short breakout up
|
||||
box.CurrentPrice = 101
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutShort || direction != "up" {
|
||||
t.Errorf("Expected short breakout up, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Mid breakout down
|
||||
box.CurrentPrice = 84
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutMid || direction != "down" {
|
||||
t.Errorf("Expected mid breakout down, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Long breakout up
|
||||
box.CurrentPrice = 112
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutLong || direction != "up" {
|
||||
t.Errorf("Expected long breakout up, got %v %v", level, direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakoutConfirmation(t *testing.T) {
|
||||
state := &BreakoutState{
|
||||
Level: market.BreakoutNone,
|
||||
Direction: "",
|
||||
ConfirmCount: 0,
|
||||
}
|
||||
|
||||
// First detection
|
||||
confirmed := confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 1 {
|
||||
t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Second confirmation
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 2 {
|
||||
t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Third confirmation - should confirm
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if !confirmed || state.ConfirmCount != 3 {
|
||||
t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Reset on price return
|
||||
state.ConfirmCount = 2
|
||||
confirmed = confirmBreakout(state, market.BreakoutNone, "")
|
||||
if state.ConfirmCount != 0 {
|
||||
t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
level market.BreakoutLevel
|
||||
expected BreakoutAction
|
||||
}{
|
||||
{market.BreakoutNone, BreakoutActionNone},
|
||||
{market.BreakoutShort, BreakoutActionReducePosition},
|
||||
{market.BreakoutMid, BreakoutActionPauseGrid},
|
||||
{market.BreakoutLong, BreakoutActionCloseAll},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.level), func(t *testing.T) {
|
||||
action := getBreakoutAction(tt.level)
|
||||
if action != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -61,7 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -78,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -89,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -106,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -118,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-1",
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now(), "order-2",
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now(), "order-3",
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now(), "order-4",
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-5",
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now(), "order-6",
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now(), "order-7",
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now(), "order-8",
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now(), "order-9",
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1402,15 +1402,12 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction with builder (xyz dex requires builder info for order routing)
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
},
|
||||
Builder: nil,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -1592,15 +1589,12 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction with builder
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
},
|
||||
Builder: nil,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -2070,7 +2064,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(fill.Time),
|
||||
Time: time.UnixMilli(fill.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -2079,7 +2073,159 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
}
|
||||
|
||||
// defaultBuilder is the builder info for order routing
|
||||
var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
// Set to nil to avoid requiring builder fee approval
|
||||
//
|
||||
// var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
// Fee: 10,
|
||||
// }
|
||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range openOrders {
|
||||
if order.Coin != symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
side := "BUY"
|
||||
if order.Side == "A" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Oid),
|
||||
Symbol: order.Coin,
|
||||
Side: side,
|
||||
PositionSide: "",
|
||||
Type: "LIMIT",
|
||||
Price: order.LimitPx,
|
||||
StopPrice: 0,
|
||||
Quantity: order.Size,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
if req.Leverage > 0 && !isXyz {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round quantity to allowed decimals
|
||||
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
|
||||
|
||||
// Round price to 5 significant figures
|
||||
roundedPrice := t.roundPriceToSigfigs(req.Price)
|
||||
|
||||
// Determine if buy or sell
|
||||
isBuy := req.Side == "BUY"
|
||||
|
||||
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
|
||||
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity,
|
||||
Price: roundedPrice,
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Limit: &hyperliquid.LimitOrderType{
|
||||
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
|
||||
},
|
||||
},
|
||||
ReduceOnly: req.ReduceOnly,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Note: Hyperliquid's Order response doesn't return the order ID directly
|
||||
// We would need to query open orders to get it, but for grid trading
|
||||
// we can track orders by price level instead
|
||||
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: roundedPrice,
|
||||
Quantity: roundedQuantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Parse order ID
|
||||
oid, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.exchange.Cancel(t.ctx, coin, oid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
if l2Book == nil || len(l2Book.Levels) < 2 {
|
||||
return nil, nil, fmt.Errorf("invalid order book data")
|
||||
}
|
||||
|
||||
// Parse bids (first level array)
|
||||
for i, level := range l2Book.Levels[0] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
bids = append(bids, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
// Parse asks (second level array)
|
||||
for i, level := range l2Book.Levels[1] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
asks = append(asks, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package trader
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClosedPnLRecord represents a single closed position record from exchange
|
||||
type ClosedPnLRecord struct {
|
||||
@@ -94,4 +98,133 @@ type Trader interface {
|
||||
// limit: max number of records to return
|
||||
// Returns accurate exit price, fees, and close reason for positions closed externally
|
||||
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
|
||||
|
||||
// GetOpenOrders Get open/pending orders from exchange
|
||||
// Returns stop-loss, take-profit, and limit orders that haven't been filled
|
||||
GetOpenOrders(symbol string) ([]OpenOrder, error)
|
||||
}
|
||||
|
||||
// OpenOrder represents a pending order on the exchange
|
||||
type OpenOrder struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT
|
||||
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
|
||||
Price float64 `json:"price"` // Order price (for limit orders)
|
||||
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW
|
||||
}
|
||||
|
||||
// LimitOrderRequest represents a limit order request for grid trading
|
||||
type LimitOrderRequest struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
|
||||
Price float64 `json:"price"` // Limit price
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
PostOnly bool `json:"post_only"` // Maker only order
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce position only
|
||||
ClientID string `json:"client_id"` // Client order ID for tracking
|
||||
}
|
||||
|
||||
// LimitOrderResult represents the result of placing a limit order
|
||||
type LimitOrderResult struct {
|
||||
OrderID string `json:"order_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"position_side"`
|
||||
Price float64 `json:"price"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
|
||||
}
|
||||
|
||||
// GridTrader extends Trader interface with limit order support for grid trading
|
||||
// Exchanges that support grid trading should implement this interface
|
||||
type GridTrader interface {
|
||||
Trader
|
||||
|
||||
// PlaceLimitOrder places a limit order at specified price
|
||||
// Returns order ID and status
|
||||
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
CancelOrder(symbol, orderID string) error
|
||||
|
||||
// GetOrderBook gets current order book (for price validation)
|
||||
// Returns best bid/ask prices
|
||||
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
|
||||
}
|
||||
|
||||
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
|
||||
// Uses stop orders as a fallback when limit orders aren't directly available
|
||||
type GridTraderAdapter struct {
|
||||
Trader
|
||||
}
|
||||
|
||||
// NewGridTraderAdapter creates an adapter for basic Trader
|
||||
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
|
||||
return &GridTraderAdapter{Trader: t}
|
||||
}
|
||||
|
||||
// PlaceLimitOrder implements limit order using available methods
|
||||
// For exchanges without native limit order support, this uses conditional orders
|
||||
func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// CRITICAL FIX: Set leverage before placing order
|
||||
if req.Leverage > 0 {
|
||||
if err := a.Trader.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Grid] Failed to set leverage %dx: %v", req.Leverage, err)
|
||||
// Continue anyway - some exchanges don't require explicit leverage setting
|
||||
}
|
||||
}
|
||||
|
||||
// Use SetStopLoss/SetTakeProfit as conditional limit orders
|
||||
// For buy orders below current price, use stop-loss mechanism
|
||||
// For sell orders above current price, use take-profit mechanism
|
||||
var err error
|
||||
if req.Side == "BUY" {
|
||||
err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price)
|
||||
} else {
|
||||
err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LimitOrderResult{
|
||||
OrderID: req.ClientID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order
|
||||
func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error {
|
||||
// Try to use CancelOrder if trader supports it directly
|
||||
if canceler, ok := a.Trader.(interface {
|
||||
CancelOrder(symbol, orderID string) error
|
||||
}); ok {
|
||||
return canceler.CancelOrder(symbol, orderID)
|
||||
}
|
||||
|
||||
// For traders that only support CancelAllOrders, log a warning
|
||||
// This is a limitation - we cannot cancel individual orders
|
||||
logger.Warnf("[Grid] Trader does not support individual order cancellation, "+
|
||||
"cannot cancel order %s. Consider using exchange-specific GridTrader implementation.", orderID)
|
||||
|
||||
// Return error instead of canceling all orders
|
||||
return fmt.Errorf("individual order cancellation not supported for this exchange")
|
||||
}
|
||||
|
||||
// GetOrderBook returns empty order book (not supported in basic Trader)
|
||||
func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Not supported, return empty
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user