mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-19 02:14:35 +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
|
type(scope): description
|
||||||
```
|
```
|
||||||
|
|
||||||
### 示例
|
### Examples
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(trader): add new trading strategy
|
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 | 说明 | 示例 |
|
| Type | Description | Example |
|
||||||
|------|------|------|
|
|------|-------------|---------|
|
||||||
| `feat` | 新功能 | `feat(trader): add stop-loss feature` |
|
| `feat` | New feature | `feat(trader): add stop-loss feature` |
|
||||||
| `fix` | Bug 修复 | `fix(api): handle null response` |
|
| `fix` | Bug fix | `fix(api): handle null response` |
|
||||||
| `docs` | 文档变更 | `docs: update installation guide` |
|
| `docs` | Documentation change | `docs: update installation guide` |
|
||||||
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` |
|
| `style` | Code formatting (no functional change) | `style: format code with prettier` |
|
||||||
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` |
|
| `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
|
||||||
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` |
|
| `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
|
||||||
| `test` | 添加或修改测试 | `test(trader): add unit tests` |
|
| `test` | Add or modify tests | `test(trader): add unit tests` |
|
||||||
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` |
|
| `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
|
||||||
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` |
|
| `ci` | CI/CD related changes | `ci: add test coverage report` |
|
||||||
| `security` | 安全相关修复 | `security: update vulnerable dependencies` |
|
| `security` | Security fixes | `security: update vulnerable dependencies` |
|
||||||
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` |
|
| `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
|
||||||
|
|
||||||
### Scope(范围)- 可选
|
### Scope - Optional
|
||||||
|
|
||||||
描述这次变更影响的范围:
|
Describes the area affected by the change:
|
||||||
|
|
||||||
| Scope | 说明 |
|
| Scope | Description |
|
||||||
|-------|------|
|
|-------|-------------|
|
||||||
| `exchange` | 交易所相关 |
|
| `exchange` | Exchange-related |
|
||||||
| `trader` | 交易员/交易策略 |
|
| `trader` | Trader/trading strategy |
|
||||||
| `ai` | AI 模型相关 |
|
| `ai` | AI model related |
|
||||||
| `api` | API 接口 |
|
| `api` | API interface |
|
||||||
| `ui` | 用户界面 |
|
| `ui` | User interface |
|
||||||
| `frontend` | 前端代码 |
|
| `frontend` | Frontend code |
|
||||||
| `backend` | 后端代码 |
|
| `backend` | Backend code |
|
||||||
| `security` | 安全相关 |
|
| `security` | Security related |
|
||||||
| `deps` | 依赖项 |
|
| `deps` | Dependencies |
|
||||||
| `workflow` | GitHub Actions workflows |
|
| `workflow` | GitHub Actions workflows |
|
||||||
| `github` | GitHub 配置 |
|
| `github` | GitHub configuration |
|
||||||
| `actions` | GitHub Actions |
|
| `actions` | GitHub Actions |
|
||||||
| `config` | 配置文件 |
|
| `config` | Configuration files |
|
||||||
| `docker` | Docker 相关 |
|
| `docker` | Docker related |
|
||||||
| `build` | 构建相关 |
|
| `build` | Build related |
|
||||||
| `release` | 发布相关 |
|
| `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
|
feat(trader): add risk management system
|
||||||
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
|
|||||||
build(docker): optimize Docker image size
|
build(docker): optimize Docker image size
|
||||||
```
|
```
|
||||||
|
|
||||||
### ⚠️ 需要改进的标题
|
### ⚠️ Titles That Need Improvement
|
||||||
|
|
||||||
| 不好的标题 | 问题 | 改进后 |
|
| Poor Title | Issue | Improved |
|
||||||
|-----------|------|--------|
|
|-----------|-------|----------|
|
||||||
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` |
|
| `update code` | Too vague | `refactor(trader): simplify order execution logic` |
|
||||||
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` |
|
| `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
|
||||||
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` |
|
| `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
|
||||||
| `changes` | 完全不符合格式 | `chore: update dependencies` |
|
| `changes` | Doesn't follow format | `chore: update dependencies` |
|
||||||
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` |
|
| `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. **不会阻止合并** ✅
|
1. **Won't block merging** ✅
|
||||||
- 检查会标记为"建议"
|
- Check is marked as "advisory"
|
||||||
- PR 仍然可以被审查和合并
|
- PR can still be reviewed and merged
|
||||||
|
|
||||||
2. **会收到友好提示** 💬
|
2. **Provides friendly reminder** 💬
|
||||||
- 机器人会在 PR 中留言
|
- Bot will comment on the PR
|
||||||
- 提供格式说明和示例
|
- Provides format guidance and examples
|
||||||
- 建议如何改进标题
|
- Suggests how to improve the title
|
||||||
|
|
||||||
3. **可以随时更新** 🔄
|
3. **Can be updated anytime** 🔄
|
||||||
- 更新 PR 标题后会重新检查
|
- Re-checks after updating PR title
|
||||||
- 无需关闭和重新打开 PR
|
- 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
|
```markdown
|
||||||
## ⚠️ PR Title Format Suggestion
|
## ⚠️ 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
|
```yaml
|
||||||
types: |
|
types: |
|
||||||
@@ -178,7 +178,7 @@ types: |
|
|||||||
build
|
build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 支持的 Scopes
|
### Supported Scopes
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
scopes: |
|
scopes: |
|
||||||
@@ -200,38 +200,38 @@ scopes: |
|
|||||||
release
|
release
|
||||||
```
|
```
|
||||||
|
|
||||||
### 添加新的 Scope
|
### Adding New Scopes
|
||||||
|
|
||||||
如果你需要添加新的 scope,请:
|
If you need to add a new scope:
|
||||||
|
|
||||||
1. 在 `.github/workflows/pr-checks.yml` 的 `scopes` 部分添加
|
1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
|
||||||
2. 在 `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选)
|
2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
|
||||||
3. 更新本文档
|
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. **语义化版本** 🔢
|
2. **Semantic Versioning** 🔢
|
||||||
- `feat` → MINOR 版本(1.1.0)
|
- `feat` → MINOR version (1.1.0)
|
||||||
- `fix` → PATCH 版本(1.0.1)
|
- `fix` → PATCH version (1.0.1)
|
||||||
- `BREAKING CHANGE` → MAJOR 版本(2.0.0)
|
- `BREAKING CHANGE` → MAJOR version (2.0.0)
|
||||||
|
|
||||||
3. **更好的可读性** 👀
|
3. **Better Readability** 👀
|
||||||
- 一眼看出 PR 的目的
|
- Understand PR purpose at a glance
|
||||||
- 更容易浏览 Git 历史
|
- Easier to browse Git history
|
||||||
|
|
||||||
4. **团队协作** 🤝
|
4. **Team Collaboration** 🤝
|
||||||
- 统一的提交风格
|
- Unified commit style
|
||||||
- 降低沟通成本
|
- Reduces communication overhead
|
||||||
|
|
||||||
### 示例:自动生成的 Changelog
|
### Example: Auto-generated Changelog
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## v1.2.0 (2025-11-02)
|
## 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
|
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
|
||||||
- **Semantic Versioning:** https://semver.org/
|
- **Semantic Versioning:** https://semver.org/
|
||||||
|
|
||||||
@@ -260,33 +260,33 @@ scopes: |
|
|||||||
|
|
||||||
## ❓ FAQ
|
## ❓ 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:** 可以,但建议:
|
**A:** Yes, but it's recommended to:
|
||||||
- 选择最主要的类型
|
- Choose the most significant type
|
||||||
- 或者考虑拆分成多个 PR(更易于审查)
|
- 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
|
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
|
```bash
|
||||||
git log --oneline --no-merges | \
|
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 等)
|
- [ ] Contains a valid type (feat, fix, docs, etc.)
|
||||||
- [ ] 使用小写字母开头
|
- [ ] Starts with lowercase
|
||||||
- [ ] 使用现在时态("add" 而不是 "added")
|
- [ ] Uses present tense ("add" not "added")
|
||||||
- [ ] 简洁明了(最好在 50 字符内)
|
- [ ] 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:
|
> 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
|
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes
|
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates
|
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes
|
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||||
>
|
>
|
||||||
> **如何使用?| How to use?**
|
> **How to use?**
|
||||||
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
|
|
||||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
> - 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
|
> - Or simply copy and paste the content from the corresponding template
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Description | 描述
|
## 📝 Description
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
<!-- Describe your changes in detail -->
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Type of Change | 变更类型
|
## 🎯 Type of Change
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix | 修复 Bug
|
- [ ] 🐛 Bug fix
|
||||||
- [ ] ✨ New feature | 新功能
|
- [ ] ✨ New feature
|
||||||
- [ ] 💥 Breaking change | 破坏性变更
|
- [ ] 💥 Breaking change
|
||||||
- [ ] 📝 Documentation update | 文档更新
|
- [ ] 📝 Documentation update
|
||||||
- [ ] 🎨 Code style update | 代码样式更新
|
- [ ] 🎨 Code style update
|
||||||
- [ ] ♻️ Refactoring | 重构
|
- [ ] ♻️ Refactoring
|
||||||
- [ ] ⚡ Performance improvement | 性能优化
|
- [ ] ⚡ Performance improvement
|
||||||
- [ ] ✅ Test update | 测试更新
|
- [ ] ✅ Test update
|
||||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
- [ ] 🔧 Build/config change
|
||||||
- [ ] 🔒 Security fix | 安全修复
|
- [ ] 🔒 Security fix
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Related Issues | 相关 Issue
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes # | 关闭 #
|
- Closes #
|
||||||
- Related to # | 相关 #
|
- Related to #
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changes Made | 具体变更
|
## 📋 Changes Made
|
||||||
|
|
||||||
**English:** | **中文:**
|
<!-- List the specific changes made -->
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Testing | 测试
|
## 🧪 Testing
|
||||||
|
|
||||||
- [ ] Tested locally | 本地测试通过
|
- [ ] Tested locally
|
||||||
- [ ] Tests pass | 测试通过
|
- [ ] Tests pass
|
||||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
- [ ] Verified no existing functionality broke
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Checklist | 检查清单
|
## ✅ Checklist
|
||||||
|
|
||||||
### Code Quality | 代码质量
|
### Code Quality
|
||||||
- [ ] Code follows project style | 代码遵循项目风格
|
- [ ] Code follows project style
|
||||||
- [ ] Self-review completed | 已完成代码自查
|
- [ ] Self-review completed
|
||||||
- [ ] Comments added for complex logic | 已添加必要注释
|
- [ ] Comments added for complex logic
|
||||||
|
|
||||||
### Documentation | 文档
|
### Documentation
|
||||||
- [ ] Updated relevant documentation | 已更新相关文档
|
- [ ] Updated relevant documentation
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
- [ ] Commits follow conventional format
|
||||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
- [ ] Rebased on latest `dev` branch
|
||||||
- [ ] No merge conflicts | 无合并冲突
|
- [ ] 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 have read the [Contributing Guidelines](../CONTRIBUTING.md)
|
||||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则
|
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
|
||||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
- [ ] 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:
|
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
|
||||||
|
|
||||||
### 1. 🔧 Backend Template | 后端模板
|
### 1. 🔧 Backend Template
|
||||||
**文件:** `backend.md`
|
**File:** `backend.md`
|
||||||
|
|
||||||
**适用于 | Use for:**
|
**Use for:**
|
||||||
- Go代码变更 | Go code changes
|
- Go code changes
|
||||||
- API端点开发 | API endpoint development
|
- API endpoint development
|
||||||
- 交易逻辑实现 | Trading logic implementation
|
- Trading logic implementation
|
||||||
- 后端性能优化 | Backend performance optimization
|
- Backend performance optimization
|
||||||
- 数据库相关改动 | Database-related changes
|
- Database-related changes
|
||||||
|
|
||||||
**包含 | Includes:**
|
**Includes:**
|
||||||
- Go测试环境配置 | Go test environment
|
- Go test environment
|
||||||
- 安全考虑检查 | Security considerations
|
- Security considerations
|
||||||
- 性能影响评估 | Performance impact assessment
|
- Performance impact assessment
|
||||||
- `go fmt` 和 `go build` 检查 | `go fmt` and `go build` checks
|
- `go fmt` and `go build` checks
|
||||||
|
|
||||||
### 2. 🎨 Frontend Template | 前端模板
|
### 2. 🎨 Frontend Template
|
||||||
**文件:** `frontend.md`
|
**File:** `frontend.md`
|
||||||
|
|
||||||
**适用于 | Use for:**
|
**Use for:**
|
||||||
- UI/UX变更 | UI/UX changes
|
- UI/UX changes
|
||||||
- React/Vue组件开发 | React/Vue component development
|
- React/Vue component development
|
||||||
- 前端样式更新 | Frontend styling updates
|
- Frontend styling updates
|
||||||
- 浏览器兼容性修复 | Browser compatibility fixes
|
- Browser compatibility fixes
|
||||||
- 前端性能优化 | Frontend performance optimization
|
- Frontend performance optimization
|
||||||
|
|
||||||
**包含 | Includes:**
|
**Includes:**
|
||||||
- 截图/演示要求 | Screenshots/demo requirements
|
- Screenshots/demo requirements
|
||||||
- 浏览器测试清单 | Browser testing checklist
|
- Browser testing checklist
|
||||||
- 国际化检查 | Internationalization checks
|
- Internationalization checks
|
||||||
- 响应式设计验证 | Responsive design verification
|
- Responsive design verification
|
||||||
- `npm run lint` 和 `npm run build` 检查 | Linting and build checks
|
- `npm run lint` and `npm run build` checks
|
||||||
|
|
||||||
### 3. 📝 Documentation Template | 文档模板
|
### 3. 📝 Documentation Template
|
||||||
**文件:** `docs.md`
|
**File:** `docs.md`
|
||||||
|
|
||||||
**适用于 | Use for:**
|
**Use for:**
|
||||||
- README更新 | README updates
|
- README updates
|
||||||
- API文档编写 | API documentation
|
- API documentation
|
||||||
- 教程和指南 | Tutorials and guides
|
- Tutorials and guides
|
||||||
- 代码注释改进 | Code comment improvements
|
- Code comment improvements
|
||||||
- 翻译工作 | Translation work
|
- Translation work
|
||||||
|
|
||||||
**包含 | Includes:**
|
**Includes:**
|
||||||
- 文档类型分类 | Documentation type classification
|
- Documentation type classification
|
||||||
- 内容质量检查 | Content quality checks
|
- Content quality checks
|
||||||
- 双语要求(中英文)| Bilingual requirements (EN/CN)
|
- Bilingual requirements (EN/CN)
|
||||||
- 链接有效性验证 | Link validity verification
|
- Link validity verification
|
||||||
|
|
||||||
### 4. 📦 General Template | 通用模板
|
### 4. 📦 General Template
|
||||||
**文件:** `general.md`
|
**File:** `general.md`
|
||||||
|
|
||||||
**适用于 | Use for:**
|
**Use for:**
|
||||||
- 混合类型变更 | Mixed-type changes
|
- Mixed-type changes
|
||||||
- 跨多个领域的PR | Cross-domain PRs
|
- Cross-domain PRs
|
||||||
- 构建配置变更 | Build configuration changes
|
- Build configuration changes
|
||||||
- 依赖更新 | Dependency updates
|
- Dependency updates
|
||||||
- 不确定使用哪个模板时 | When unsure which template to use
|
- 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:
|
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
|
||||||
|
|
||||||
### 工作原理 | How it works:
|
### How it works:
|
||||||
|
|
||||||
1. **文件分析 | File Analysis**
|
1. **File Analysis**
|
||||||
- 检测PR中所有变更的文件类型
|
|
||||||
- Detects all changed file types in the PR
|
- Detects all changed file types in the PR
|
||||||
|
|
||||||
2. **智能判断 | Smart Detection**
|
2. **Smart Detection**
|
||||||
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
|
|
||||||
- If >50% are `.go` files → Suggests **Backend template**
|
- If >50% are `.go` files → Suggests **Backend template**
|
||||||
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
|
|
||||||
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
||||||
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
|
|
||||||
- If >70% are `.md` files → Suggests **Documentation template**
|
- 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
|
- If it detects you're using the default template but should use a specialized one
|
||||||
- 会自动添加友好的评论建议
|
|
||||||
- It will automatically add a friendly comment suggestion
|
- It will automatically add a friendly comment suggestion
|
||||||
|
|
||||||
4. **自动标签 | Auto-labeling**
|
4. **Auto-labeling**
|
||||||
- 自动添加对应的标签:`backend`、`frontend`、`documentation`
|
|
||||||
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
|
- 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:
|
When creating a PR, add the template parameter to the URL:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
||||||
```
|
```
|
||||||
|
|
||||||
替换 `backend.md` 为:
|
|
||||||
Replace `backend.md` with:
|
Replace `backend.md` with:
|
||||||
- `backend.md` - 后端模板 | Backend template
|
- `backend.md` - Backend template
|
||||||
- `frontend.md` - 前端模板 | Frontend template
|
- `frontend.md` - Frontend template
|
||||||
- `docs.md` - 文档模板 | Documentation template
|
- `docs.md` - Documentation template
|
||||||
- `general.md` - 通用模板 | General template
|
- `general.md` - General template
|
||||||
|
|
||||||
### 方法2: 手动选择 | Method 2: Manual Selection
|
### Method 2: Manual Selection
|
||||||
|
|
||||||
1. 创建PR时,默认模板会显示
|
1. When creating a PR, the default template will be shown
|
||||||
When creating a PR, the default template will be shown
|
|
||||||
|
|
||||||
2. 根据顶部的指引链接,点击查看对应的模板
|
2. Follow the guidance links at the top to view the corresponding template
|
||||||
Follow the guidance links at the top to view the corresponding template
|
|
||||||
|
|
||||||
3. 复制模板内容到PR描述中
|
3. Copy the template content into the PR description
|
||||||
Copy the template content into the PR description
|
|
||||||
|
|
||||||
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
|
### Method 3: Follow Auto-suggestion
|
||||||
|
|
||||||
1. 使用任何模板创建PR
|
1. Create a PR with any template
|
||||||
Create a PR with any template
|
|
||||||
|
|
||||||
2. GitHub Action会自动分析并评论建议
|
2. GitHub Action will automatically analyze and comment with a suggestion
|
||||||
GitHub Action will automatically analyze and comment with a suggestion
|
|
||||||
|
|
||||||
3. 根据建议更新PR描述
|
3. Update the PR description based on the suggestion
|
||||||
Update the PR description based on the suggestion
|
|
||||||
|
|
||||||
## 🎯 最佳实践 | Best Practices
|
## 🎯 Best Practices
|
||||||
|
|
||||||
1. **提前选择 | Choose in Advance**
|
1. **Choose in Advance**
|
||||||
- 在创建PR前确定变更类型
|
|
||||||
- Determine the change type before creating the PR
|
- Determine the change type before creating the PR
|
||||||
|
|
||||||
2. **完整填写 | Complete Filling**
|
2. **Complete Filling**
|
||||||
- 不要跳过必填项(标记为 required)
|
|
||||||
- Don't skip required items
|
- Don't skip required items
|
||||||
|
|
||||||
3. **保持简洁 | Keep it Concise**
|
3. **Keep it Concise**
|
||||||
- 描述清晰但简洁
|
|
||||||
- Keep descriptions clear but concise
|
- Keep descriptions clear but concise
|
||||||
|
|
||||||
4. **添加截图 | Add Screenshots**
|
4. **Add Screenshots**
|
||||||
- 对于UI变更,务必添加截图
|
|
||||||
- For UI changes, always add screenshots
|
- For UI changes, always add screenshots
|
||||||
|
|
||||||
5. **测试证明 | Test Evidence**
|
5. **Test Evidence**
|
||||||
- 提供测试通过的证据
|
|
||||||
- Provide evidence that tests pass
|
- Provide evidence that tests pass
|
||||||
|
|
||||||
## 🔧 自定义 | Customization
|
## 🔧 Customization
|
||||||
|
|
||||||
如果需要修改模板或自动检测逻辑:
|
|
||||||
If you need to modify templates or auto-detection logic:
|
If you need to modify templates or auto-detection logic:
|
||||||
|
|
||||||
1. **修改模板** | **Modify Templates**
|
1. **Modify Templates**
|
||||||
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
|
|
||||||
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
||||||
|
|
||||||
2. **调整检测阈值** | **Adjust Detection Threshold**
|
2. **Adjust Detection Threshold**
|
||||||
- 编辑 `.github/workflows/pr-template-suggester.yml`
|
|
||||||
- Edit `.github/workflows/pr-template-suggester.yml`
|
- Edit `.github/workflows/pr-template-suggester.yml`
|
||||||
- 修改文件类型占比阈值(当前:50%后端,50%前端,70%文档)
|
|
||||||
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
||||||
|
|
||||||
3. **添加新模板** | **Add New Template**
|
3. **Add New Template**
|
||||||
- 在 `PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
|
|
||||||
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
||||||
- 更新工作流以支持新的文件类型检测
|
|
||||||
- Update the workflow to support new file type detection
|
- Update the workflow to support new file type detection
|
||||||
|
|
||||||
## ❓ FAQ
|
## ❓ FAQ
|
||||||
|
|
||||||
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
|
|
||||||
**Q: My PR has both frontend and backend code, which template should I use?**
|
**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.
|
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?**
|
**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.
|
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?**
|
**Q: Can I not use any template?**
|
||||||
|
|
||||||
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
|
|
||||||
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Q: 如何禁用自动模板建议?**
|
|
||||||
**Q: How to disable automatic template suggestions?**
|
**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.
|
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`
|
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Description | 描述
|
## 📝 Description
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Type of Change | 变更类型
|
## 🎯 Type of Change
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix | 修复 Bug
|
- [ ] 🐛 Bug fix
|
||||||
- [ ] ✨ New feature | 新功能
|
- [ ] ✨ New feature
|
||||||
- [ ] 💥 Breaking change | 破坏性变更
|
- [ ] 💥 Breaking change
|
||||||
- [ ] ♻️ Refactoring | 重构
|
- [ ] ♻️ Refactoring
|
||||||
- [ ] ⚡ Performance improvement | 性能优化
|
- [ ] ⚡ Performance improvement
|
||||||
- [ ] 🔒 Security fix | 安全修复
|
- [ ] 🔒 Security fix
|
||||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
- [ ] 🔧 Build/config change
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Related Issues | 相关 Issue
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes # | 关闭 #
|
- Closes #
|
||||||
- Related to # | 相关 #
|
- Related to #
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changes Made | 具体变更
|
## 📋 Changes Made
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Testing | 测试
|
## 🧪 Testing
|
||||||
|
|
||||||
### Test Environment | 测试环境
|
### Test Environment
|
||||||
- **OS | 操作系统:**
|
- **OS:**
|
||||||
- **Go Version | Go 版本:**
|
- **Go Version:**
|
||||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
- **Exchange:** [if applicable]
|
||||||
|
|
||||||
### Manual Testing | 手动测试
|
### Manual Testing
|
||||||
- [ ] Tested locally | 本地测试通过
|
- [ ] Tested locally
|
||||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
- [ ] Tested on testnet (for exchange integration)
|
||||||
- [ ] Unit tests pass | 单元测试通过
|
- [ ] Unit tests pass
|
||||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
- [ ] 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 密钥
|
- [ ] No API keys or secrets hardcoded
|
||||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
- [ ] User inputs properly validated
|
||||||
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
|
- [ ] No SQL injection vulnerabilities
|
||||||
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
|
- [ ] Authentication/authorization properly handled
|
||||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
- [ ] Sensitive data is encrypted
|
||||||
- [ ] N/A (not security-related) | 不适用
|
- [ ] N/A (not security-related)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚡ Performance Impact | 性能影响
|
## ⚡ Performance Impact
|
||||||
|
|
||||||
- [ ] No significant performance impact | 无显著性能影响
|
- [ ] No significant performance impact
|
||||||
- [ ] Performance improved | 性能提升
|
- [ ] Performance improved
|
||||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响
|
- [ ] Performance may be impacted (explain below)
|
||||||
|
|
||||||
**If impacted, explain | 如果受影响,请说明:**
|
**If impacted, explain:**
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Checklist | 检查清单
|
## ✅ Checklist
|
||||||
|
|
||||||
### Code Quality | 代码质量
|
### Code Quality
|
||||||
- [ ] Code follows project style | 代码遵循项目风格
|
- [ ] Code follows project style
|
||||||
- [ ] Self-review completed | 已完成代码自查
|
- [ ] Self-review completed
|
||||||
- [ ] Comments added for complex logic | 已添加必要注释
|
- [ ] Comments added for complex logic
|
||||||
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
|
- [ ] Code compiles successfully (`go build`)
|
||||||
- [ ] Ran `go fmt` | 已运行 `go fmt`
|
- [ ] Ran `go fmt`
|
||||||
|
|
||||||
### Documentation | 文档
|
### Documentation
|
||||||
- [ ] Updated relevant documentation | 已更新相关文档
|
- [ ] Updated relevant documentation
|
||||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
- [ ] Added inline comments where necessary
|
||||||
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
|
- [ ] Updated API documentation (if applicable)
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
- [ ] Commits follow conventional format
|
||||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
- [ ] Rebased on latest `dev` branch
|
||||||
- [ ] No merge conflicts | 无合并冲突
|
- [ ] No merge conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Additional Notes | 补充说明
|
## 📚 Additional Notes
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
**By submitting this PR, I confirm:**
|
||||||
|
|
||||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
- [ ] 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`
|
> **💡 Tip:** Recommended PR title format `docs(scope): description`
|
||||||
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
> Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Description | 描述
|
## 📝 Description
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Type of Documentation | 文档类型
|
## 📚 Type of Documentation
|
||||||
|
|
||||||
- [ ] 📖 README update | README 更新
|
- [ ] 📖 README update
|
||||||
- [ ] 📋 API documentation | API 文档
|
- [ ] 📋 API documentation
|
||||||
- [ ] 🎓 Tutorial/Guide | 教程/指南
|
- [ ] 🎓 Tutorial/Guide
|
||||||
- [ ] 📝 Code comments | 代码注释
|
- [ ] 📝 Code comments
|
||||||
- [ ] 🔧 Configuration docs | 配置文档
|
- [ ] 🔧 Configuration docs
|
||||||
- [ ] 🐛 Fix typo/error | 修复拼写/错误
|
- [ ] 🐛 Fix typo/error
|
||||||
- [ ] 🌍 Translation | 翻译
|
- [ ] 🌍 Translation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Related Issues | 相关 Issue
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes # | 关闭 #
|
- Closes #
|
||||||
- Related to # | 相关 #
|
- Related to #
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changes Made | 具体变更
|
## 📋 Changes Made
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Screenshots (if applicable) | 截图(如适用)
|
## 📸 Screenshots (if applicable)
|
||||||
|
|
||||||
<!-- For documentation with images, diagrams, or UI examples -->
|
<!-- For documentation with images, diagrams, or UI examples -->
|
||||||
<!-- 用于包含图片、图表或 UI 示例的文档 -->
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Internationalization | 国际化
|
## 🌐 Internationalization
|
||||||
|
|
||||||
- [ ] English version complete | 英文版本完整
|
- [ ] English version complete
|
||||||
- [ ] Chinese version complete | 中文版本完整
|
- [ ] Chinese version complete
|
||||||
- [ ] Both versions are consistent | 两个版本内容一致
|
- [ ] Both versions are consistent
|
||||||
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
|
- [ ] N/A (only one language needed)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Checklist | 检查清单
|
## ✅ Checklist
|
||||||
|
|
||||||
### Content Quality | 内容质量
|
### Content Quality
|
||||||
- [ ] Information is accurate and up-to-date | 信息准确且最新
|
- [ ] Information is accurate and up-to-date
|
||||||
- [ ] Language is clear and concise | 语言清晰简洁
|
- [ ] Language is clear and concise
|
||||||
- [ ] No spelling or grammar errors | 无拼写或语法错误
|
- [ ] No spelling or grammar errors
|
||||||
- [ ] Links are valid and working | 链接有效且可用
|
- [ ] Links are valid and working
|
||||||
- [ ] Code examples are tested and working | 代码示例已测试且可用
|
- [ ] Code examples are tested and working
|
||||||
- [ ] Formatting is consistent | 格式一致
|
- [ ] Formatting is consistent
|
||||||
|
|
||||||
### Documentation Standards | 文档标准
|
### Documentation Standards
|
||||||
- [ ] Follows project documentation style | 遵循项目文档风格
|
- [ ] Follows project documentation style
|
||||||
- [ ] Includes necessary examples | 包含必要的示例
|
- [ ] Includes necessary examples
|
||||||
- [ ] Technical terms are explained | 技术术语已解释
|
- [ ] Technical terms are explained
|
||||||
- [ ] Self-review completed | 已完成自查
|
- [ ] Self-review completed
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
- [ ] Commits follow conventional format
|
||||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
- [ ] Rebased on latest `dev` branch
|
||||||
- [ ] No merge conflicts | 无合并冲突
|
- [ ] No merge conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Additional Notes | 补充说明
|
## 📚 Additional Notes
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
**By submitting this PR, I confirm:**
|
||||||
|
|
||||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
- [ ] 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`
|
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||||
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
> Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Description | 描述
|
## 📝 Description
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Type of Change | 变更类型
|
## 🎯 Type of Change
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix | 修复 Bug
|
- [ ] 🐛 Bug fix
|
||||||
- [ ] ✨ New feature | 新功能
|
- [ ] ✨ New feature
|
||||||
- [ ] 💥 Breaking change | 破坏性变更
|
- [ ] 💥 Breaking change
|
||||||
- [ ] 🎨 Code style update | 代码样式更新
|
- [ ] 🎨 Code style update
|
||||||
- [ ] ♻️ Refactoring | 重构
|
- [ ] ♻️ Refactoring
|
||||||
- [ ] ⚡ Performance improvement | 性能优化
|
- [ ] ⚡ Performance improvement
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Related Issues | 相关 Issue
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes # | 关闭 #
|
- Closes #
|
||||||
- Related to # | 相关 #
|
- Related to #
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changes Made | 具体变更
|
## 📋 Changes Made
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📸 Screenshots / Demo | 截图/演示
|
## 📸 Screenshots / Demo
|
||||||
|
|
||||||
<!-- For UI changes, include before/after screenshots or video demo -->
|
<!-- For UI changes, include before/after screenshots or video demo -->
|
||||||
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
|
|
||||||
|
|
||||||
**Before | 变更前:**
|
**Before:**
|
||||||
|
|
||||||
|
|
||||||
**After | 变更后:**
|
**After:**
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Testing | 测试
|
## 🧪 Testing
|
||||||
|
|
||||||
### Test Environment | 测试环境
|
### Test Environment
|
||||||
- **OS | 操作系统:**
|
- **OS:**
|
||||||
- **Node Version | Node 版本:**
|
- **Node Version:**
|
||||||
- **Browser(s) | 浏览器:**
|
- **Browser(s):**
|
||||||
|
|
||||||
### Manual Testing | 手动测试
|
### Manual Testing
|
||||||
- [ ] Tested in development mode | 开发模式测试通过
|
- [ ] Tested in development mode
|
||||||
- [ ] Tested production build | 生产构建测试通过
|
- [ ] Tested production build
|
||||||
- [ ] Tested on multiple browsers | 多浏览器测试通过
|
- [ ] Tested on multiple browsers
|
||||||
- [ ] Tested responsive design | 响应式设计测试通过
|
- [ ] Tested responsive design
|
||||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
- [ ] Verified no existing functionality broke
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌐 Internationalization | 国际化
|
## 🌐 Internationalization
|
||||||
|
|
||||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
- [ ] All user-facing text supports i18n
|
||||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
- [ ] Both English and Chinese versions provided
|
||||||
- [ ] N/A | 不适用
|
- [ ] N/A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Checklist | 检查清单
|
## ✅ Checklist
|
||||||
|
|
||||||
### Code Quality | 代码质量
|
### Code Quality
|
||||||
- [ ] Code follows project style | 代码遵循项目风格
|
- [ ] Code follows project style
|
||||||
- [ ] Self-review completed | 已完成代码自查
|
- [ ] Self-review completed
|
||||||
- [ ] Comments added for complex logic | 已添加必要注释
|
- [ ] Comments added for complex logic
|
||||||
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
|
- [ ] Code builds successfully (`npm run build`)
|
||||||
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
|
- [ ] Ran `npm run lint`
|
||||||
- [ ] No console errors or warnings | 无控制台错误或警告
|
- [ ] No console errors or warnings
|
||||||
|
|
||||||
### Testing | 测试
|
### Testing
|
||||||
- [ ] Component tests added/updated | 已添加/更新组件测试
|
- [ ] Component tests added/updated
|
||||||
- [ ] Tests pass locally | 测试在本地通过
|
- [ ] Tests pass locally
|
||||||
|
|
||||||
### Documentation | 文档
|
### Documentation
|
||||||
- [ ] Updated relevant documentation | 已更新相关文档
|
- [ ] Updated relevant documentation
|
||||||
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
|
- [ ] Updated type definitions (TypeScript)
|
||||||
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
|
- [ ] Added JSDoc comments where necessary
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
- [ ] Commits follow conventional format
|
||||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
- [ ] Rebased on latest `dev` branch
|
||||||
- [ ] No merge conflicts | 无合并冲突
|
- [ ] No merge conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Additional Notes | 补充说明
|
## 📚 Additional Notes
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
**By submitting this PR, I confirm:**
|
||||||
|
|
||||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
- [ ] 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`
|
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Description | 描述
|
## 📝 Description
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Type of Change | 变更类型
|
## 🎯 Type of Change
|
||||||
|
|
||||||
- [ ] 🐛 Bug fix | 修复 Bug
|
- [ ] 🐛 Bug fix
|
||||||
- [ ] ✨ New feature | 新功能
|
- [ ] ✨ New feature
|
||||||
- [ ] 💥 Breaking change | 破坏性变更
|
- [ ] 💥 Breaking change
|
||||||
- [ ] 📝 Documentation update | 文档更新
|
- [ ] 📝 Documentation update
|
||||||
- [ ] 🎨 Code style update | 代码样式更新
|
- [ ] 🎨 Code style update
|
||||||
- [ ] ♻️ Refactoring | 重构
|
- [ ] ♻️ Refactoring
|
||||||
- [ ] ⚡ Performance improvement | 性能优化
|
- [ ] ⚡ Performance improvement
|
||||||
- [ ] ✅ Test update | 测试更新
|
- [ ] ✅ Test update
|
||||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
- [ ] 🔧 Build/config change
|
||||||
- [ ] 🔒 Security fix | 安全修复
|
- [ ] 🔒 Security fix
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Related Issues | 相关 Issue
|
## 🔗 Related Issues
|
||||||
|
|
||||||
- Closes # | 关闭 #
|
- Closes #
|
||||||
- Related to # | 相关 #
|
- Related to #
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changes Made | 具体变更
|
## 📋 Changes Made
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
-
|
-
|
||||||
-
|
-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🧪 Testing | 测试
|
## 🧪 Testing
|
||||||
|
|
||||||
- [ ] Tested locally | 本地测试通过
|
- [ ] Tested locally
|
||||||
- [ ] Tests pass | 测试通过
|
- [ ] Tests pass
|
||||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
- [ ] Verified no existing functionality broke
|
||||||
|
|
||||||
**Test details | 测试详情:**
|
**Test details:**
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Checklist | 检查清单
|
## ✅ Checklist
|
||||||
|
|
||||||
### Code Quality | 代码质量
|
### Code Quality
|
||||||
- [ ] Code follows project style | 代码遵循项目风格
|
- [ ] Code follows project style
|
||||||
- [ ] Self-review completed | 已完成代码自查
|
- [ ] Self-review completed
|
||||||
- [ ] Comments added for complex logic | 已添加必要注释
|
- [ ] Comments added for complex logic
|
||||||
- [ ] No new warnings or errors | 无新的警告或错误
|
- [ ] No new warnings or errors
|
||||||
|
|
||||||
### Documentation | 文档
|
### Documentation
|
||||||
- [ ] Updated relevant documentation | 已更新相关文档
|
- [ ] Updated relevant documentation
|
||||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
- [ ] Added inline comments where necessary
|
||||||
|
|
||||||
### Git
|
### Git
|
||||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
- [ ] Commits follow conventional format
|
||||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
- [ ] Rebased on latest `dev` branch
|
||||||
- [ ] No merge conflicts | 无合并冲突
|
- [ ] No merge conflicts
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔒 Security (if applicable) | 安全(如适用)
|
## 🔒 Security (if applicable)
|
||||||
|
|
||||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
- [ ] No API keys or secrets hardcoded
|
||||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
- [ ] User inputs properly validated
|
||||||
- [ ] N/A | 不适用
|
- [ ] N/A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Additional Notes | 补充说明
|
## 📚 Additional Notes
|
||||||
|
|
||||||
**English:** | **中文:**
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
**By submitting this PR, I confirm:**
|
||||||
|
|
||||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
- [ ] 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:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
|
- release/stable
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -152,12 +153,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
|
IMAGE_BASE: ${{ needs.prepare.outputs.image_base }}
|
||||||
run: |
|
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 }}"
|
GHCR_IMAGE="${{ env.REGISTRY_GHCR }}/${IMAGE_BASE}/nofx-${{ matrix.image_suffix }}"
|
||||||
|
|
||||||
echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
|
echo "📦 Creating manifest for ${{ matrix.image_suffix }}"
|
||||||
echo "Repository: ${IMAGE_BASE}"
|
echo "Repository: ${IMAGE_BASE}"
|
||||||
echo "Image: ${GHCR_IMAGE}"
|
echo "Image: ${GHCR_IMAGE}"
|
||||||
|
echo "Ref name: ${REF_NAME}"
|
||||||
|
|
||||||
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
|
docker buildx imagetools create -t "${GHCR_IMAGE}:${REF_NAME}" \
|
||||||
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
"${GHCR_IMAGE}:${REF_NAME}-amd64" \
|
||||||
@@ -171,6 +174,14 @@ jobs:
|
|||||||
echo "✅ Created latest tag (main branch only)"
|
echo "✅ Created latest tag (main branch only)"
|
||||||
fi
|
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
|
if [[ -n "${{ secrets.DOCKERHUB_USERNAME }}" ]]; then
|
||||||
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
|
DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/nofx-${{ matrix.image_suffix }}"
|
||||||
docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:${REF_NAME}" \
|
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)
|
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||||
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
- **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!
|
> **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
|
## 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
|
## Screenshots
|
||||||
|
|
||||||
### Config Page
|
### 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
|
## Quick Start
|
||||||
|
|
||||||
### One-Click Install (Recommended)
|
### One-Click Install (Local/Server)
|
||||||
|
|
||||||
**Linux / macOS:**
|
**Linux / macOS:**
|
||||||
```bash
|
```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.
|
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)
|
### Docker Compose (Manual)
|
||||||
|
|
||||||
```bash
|
```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
|
## Star History
|
||||||
|
|
||||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"nofx/backtest"
|
"nofx/backtest"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -60,7 +60,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
|
|
||||||
var req backtestStartRequest
|
var req backtestStartRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,23 +78,23 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
if cfg.StrategyID != "" {
|
if cfg.StrategyID != "" {
|
||||||
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
strategy, err := s.store.Strategy().Get(cfg.UserID, cfg.StrategyID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if strategy == nil {
|
if strategy == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("strategy not found: %s", cfg.StrategyID)})
|
SafeBadRequest(c, "Strategy not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var strategyConfig store.StrategyConfig
|
var strategyConfig store.StrategyConfig
|
||||||
if err := json.Unmarshal([]byte(strategy.Config), &strategyConfig); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
cfg.SetLoadedStrategy(&strategyConfig)
|
cfg.SetLoadedStrategy(&strategyConfig)
|
||||||
logger.Infof("📊 Backtest using saved strategy: %s (%s)", strategy.Name, strategy.ID)
|
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.SourceType,
|
||||||
strategyConfig.CoinSource.UseCoinPool,
|
strategyConfig.CoinSource.UseAI500,
|
||||||
strategyConfig.CoinSource.UseOITop,
|
strategyConfig.CoinSource.UseOITop,
|
||||||
strategyConfig.CoinSource.StaticCoins)
|
strategyConfig.CoinSource.StaticCoins)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
if len(cfg.Symbols) == 0 {
|
if len(cfg.Symbols) == 0 {
|
||||||
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
symbols, err := s.resolveStrategyCoins(&strategyConfig)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
cfg.Symbols = symbols
|
cfg.Symbols = symbols
|
||||||
@@ -111,7 +111,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
if err := s.hydrateBacktestAIConfig(&cfg); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Failed to configure AI model")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ func (s *Server) handleBacktestStart(c *gin.Context) {
|
|||||||
|
|
||||||
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
runner, err := s.backtestManager.Start(context.Background(), cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeError(c, http.StatusBadRequest, "Failed to start backtest", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,11 +149,11 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
|||||||
|
|
||||||
var req runIDRequest
|
var req runIDRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.RunID == "" {
|
if req.RunID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
SafeBadRequest(c, "run_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ func (s *Server) handleBacktestControl(c *gin.Context, fn func(string) error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := fn(req.RunID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,11 +181,11 @@ func (s *Server) handleBacktestLabel(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var req labelRequest
|
var req labelRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.RunID) == "" {
|
if strings.TrimSpace(req.RunID) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
SafeBadRequest(c, "run_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := normalizeUserID(c.GetString("user_id"))
|
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)
|
meta, err := s.backtestManager.UpdateLabel(req.RunID, req.Label)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Update backtest label", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, meta)
|
c.JSON(http.StatusOK, meta)
|
||||||
@@ -207,11 +207,11 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var req runIDRequest
|
var req runIDRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.RunID) == "" {
|
if strings.TrimSpace(req.RunID) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id is required"})
|
SafeBadRequest(c, "run_id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := normalizeUserID(c.GetString("user_id"))
|
userID := normalizeUserID(c.GetString("user_id"))
|
||||||
@@ -219,7 +219,7 @@ func (s *Server) handleBacktestDelete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.backtestManager.Delete(req.RunID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
@@ -277,7 +277,7 @@ func (s *Server) handleBacktestRuns(c *gin.Context) {
|
|||||||
|
|
||||||
metas, err := s.backtestManager.ListRuns()
|
metas, err := s.backtestManager.ListRuns()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "List backtest runs", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stateFilter := strings.ToLower(strings.TrimSpace(c.Query("state")))
|
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)
|
points, err := s.backtestManager.LoadEquity(runID, timeframe, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeError(c, http.StatusBadRequest, "Failed to load equity data", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, points)
|
c.JSON(http.StatusOK, points)
|
||||||
@@ -375,7 +375,7 @@ func (s *Server) handleBacktestTrades(c *gin.Context) {
|
|||||||
|
|
||||||
events, err := s.backtestManager.LoadTrades(runID, limit)
|
events, err := s.backtestManager.LoadTrades(runID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeError(c, http.StatusBadRequest, "Failed to load trades", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, events)
|
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"})
|
c.JSON(http.StatusAccepted, gin.H{"error": "metrics not ready yet"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeError(c, http.StatusBadRequest, "Failed to load metrics", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, metrics)
|
c.JSON(http.StatusOK, metrics)
|
||||||
@@ -427,7 +427,7 @@ func (s *Server) handleBacktestTrace(c *gin.Context) {
|
|||||||
cycle := queryInt(c, "cycle", 0)
|
cycle := queryInt(c, "cycle", 0)
|
||||||
record, err := s.backtestManager.GetTrace(runID, cycle)
|
record, err := s.backtestManager.GetTrace(runID, cycle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trace record")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, record)
|
c.JSON(http.StatusOK, record)
|
||||||
@@ -461,7 +461,7 @@ func (s *Server) handleBacktestDecisions(c *gin.Context) {
|
|||||||
|
|
||||||
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
records, err := backtest.LoadDecisionRecords(runID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Load decision records", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, records)
|
c.JSON(http.StatusOK, records)
|
||||||
@@ -483,7 +483,7 @@ func (s *Server) handleBacktestExport(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
path, err := s.backtestManager.ExportRun(runID)
|
path, err := s.backtestManager.ExportRun(runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeError(c, http.StatusBadRequest, "Failed to export backtest", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer os.Remove(path)
|
defer os.Remove(path)
|
||||||
@@ -536,8 +536,7 @@ func (s *Server) handleBacktestKlines(c *gin.Context) {
|
|||||||
|
|
||||||
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
klines, err := market.GetKlinesRange(symbol, timeframe, startTime, endTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to fetch klines for %s: %v", symbol, err)
|
SafeInternalError(c, "Fetch klines", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to fetch klines: %v", err)})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,11 +619,11 @@ func writeBacktestAccessError(c *gin.Context, err error) bool {
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, errBacktestForbidden):
|
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):
|
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:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Access backtest", err)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -639,21 +638,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
var symbols []string
|
var symbols []string
|
||||||
symbolSet := make(map[string]bool)
|
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
|
// Handle empty source_type - check flags for backward compatibility
|
||||||
sourceType := coinSource.SourceType
|
sourceType := coinSource.SourceType
|
||||||
if sourceType == "" {
|
if sourceType == "" {
|
||||||
if coinSource.UseCoinPool && coinSource.UseOITop {
|
if coinSource.UseAI500 && coinSource.UseOITop {
|
||||||
sourceType = "mixed"
|
sourceType = "mixed"
|
||||||
} else if coinSource.UseCoinPool {
|
} else if coinSource.UseAI500 {
|
||||||
sourceType = "coinpool"
|
sourceType = "ai500"
|
||||||
} else if coinSource.UseOITop {
|
} else if coinSource.UseOITop {
|
||||||
sourceType = "oi_top"
|
sourceType = "oi_top"
|
||||||
} else if len(coinSource.StaticCoins) > 0 {
|
} else if len(coinSource.StaticCoins) > 0 {
|
||||||
@@ -674,13 +665,13 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "coinpool":
|
case "ai500":
|
||||||
limit := coinSource.CoinPoolLimit
|
limit := coinSource.AI500Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
logger.Infof("📊 Fetching AI500 coins with limit=%d", limit)
|
||||||
coins, err := provider.GetTopRatedCoins(limit)
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get AI500 coins: %w", err)
|
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":
|
case "oi_top":
|
||||||
coins, err := provider.GetOITopSymbols()
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get OI Top coins: %w", err)
|
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":
|
case "mixed":
|
||||||
// Get from coin pool
|
// Get from AI500
|
||||||
if coinSource.UseCoinPool {
|
if coinSource.UseAI500 {
|
||||||
limit := coinSource.CoinPoolLimit
|
limit := coinSource.AI500Limit
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
coins, err := provider.GetTopRatedCoins(limit)
|
coins, err := nofxos.DefaultClient().GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to get AI500 coins: %v", err)
|
logger.Warnf("Failed to get AI500 coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -736,7 +727,7 @@ func (s *Server) resolveStrategyCoins(strategyConfig *store.StrategyConfig) ([]s
|
|||||||
|
|
||||||
// Get from OI Top
|
// Get from OI Top
|
||||||
if coinSource.UseOITop {
|
if coinSource.UseOITop {
|
||||||
coins, err := provider.GetOITopSymbols()
|
coins, err := nofxos.DefaultClient().GetOITopSymbols()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to get OI Top coins: %v", err)
|
logger.Warnf("Failed to get OI Top coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"nofx/debate"
|
"nofx/debate"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -131,7 +131,7 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
|||||||
|
|
||||||
var req CreateDebateRequest
|
var req CreateDebateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,35 +158,27 @@ func (h *DebateHandler) HandleCreateDebate(c *gin.Context) {
|
|||||||
if len(coinSource.StaticCoins) > 0 {
|
if len(coinSource.StaticCoins) > 0 {
|
||||||
req.Symbol = coinSource.StaticCoins[0]
|
req.Symbol = coinSource.StaticCoins[0]
|
||||||
}
|
}
|
||||||
case "coinpool":
|
case "ai500":
|
||||||
// Fetch from coin pool API
|
// Fetch from AI500 API
|
||||||
if coinSource.CoinPoolAPIURL != "" {
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
|
||||||
}
|
|
||||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = 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":
|
case "oi_top":
|
||||||
// Fetch from OI top API
|
// Fetch from OI top API
|
||||||
if coinSource.OITopAPIURL != "" {
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
|
||||||
}
|
|
||||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = coins[0]
|
req.Symbol = coins[0]
|
||||||
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
logger.Infof("Fetched coin from OI Top API: %s", req.Symbol)
|
||||||
}
|
}
|
||||||
case "mixed":
|
case "mixed":
|
||||||
// Try coin pool first, then OI top
|
// Try AI500 first, then OI top
|
||||||
if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" {
|
if coinSource.UseAI500 {
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
if coins, err := nofxos.DefaultClient().GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
||||||
if coins, err := provider.GetTopRatedCoins(1); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = 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 != "" {
|
} else if coinSource.UseOITop {
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
if coins, err := nofxos.DefaultClient().GetOITopSymbols(); err == nil && len(coins) > 0 {
|
||||||
if coins, err := provider.GetOITopSymbols(); err == nil && len(coins) > 0 {
|
|
||||||
req.Symbol = coins[0]
|
req.Symbol = coins[0]
|
||||||
logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol)
|
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
|
// Start debate asynchronously
|
||||||
if err := h.engine.StartDebate(debateID); err != nil {
|
if err := h.engine.StartDebate(debateID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Start debate", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +308,7 @@ func (h *DebateHandler) HandleCancelDebate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.engine.CancelDebate(debateID); err != nil {
|
if err := h.engine.CancelDebate(debateID); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Cancel debate", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,20 +487,20 @@ func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) {
|
|||||||
// Parse request
|
// Parse request
|
||||||
var req ExecuteDebateRequest
|
var req ExecuteDebateRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get trader executor
|
// Get trader executor
|
||||||
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
executor, err := h.traderManager.GetTraderExecutor(req.TraderID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute consensus
|
// Execute consensus
|
||||||
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
if err := h.engine.ExecuteConsensus(debateID, executor); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
SafeInternalError(c, "Execute consensus", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +627,9 @@ func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.Deb
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DebateHandler) broadcastError(sessionID string, err error) {
|
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{}{
|
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/sync-balance", s.handleSyncBalance)
|
||||||
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||||
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
||||||
|
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||||||
|
|
||||||
// AI model configuration
|
// AI model configuration
|
||||||
protected.GET("/models", s.handleGetModelConfigs)
|
protected.GET("/models", s.handleGetModelConfigs)
|
||||||
@@ -202,6 +203,7 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.GET("/trades", s.handleTrades)
|
protected.GET("/trades", s.handleTrades)
|
||||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
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", s.handleDecisions)
|
||||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||||
protected.GET("/statistics", s.handleStatistics)
|
protected.GET("/statistics", s.handleStatistics)
|
||||||
@@ -406,7 +408,7 @@ type CreateTraderRequest struct {
|
|||||||
CustomPrompt string `json:"custom_prompt"`
|
CustomPrompt string `json:"custom_prompt"`
|
||||||
OverrideBasePrompt bool `json:"override_base_prompt"`
|
OverrideBasePrompt bool `json:"override_base_prompt"`
|
||||||
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
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"`
|
UseOITop bool `json:"use_oi_top"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +488,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
var req CreateTraderRequest
|
var req CreateTraderRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,7 +668,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
BTCETHLeverage: btcEthLeverage,
|
BTCETHLeverage: btcEthLeverage,
|
||||||
AltcoinLeverage: altcoinLeverage,
|
AltcoinLeverage: altcoinLeverage,
|
||||||
TradingSymbols: req.TradingSymbols,
|
TradingSymbols: req.TradingSymbols,
|
||||||
UseCoinPool: req.UseCoinPool,
|
UseAI500: req.UseAI500,
|
||||||
UseOITop: req.UseOITop,
|
UseOITop: req.UseOITop,
|
||||||
CustomPrompt: req.CustomPrompt,
|
CustomPrompt: req.CustomPrompt,
|
||||||
OverrideBasePrompt: req.OverrideBasePrompt,
|
OverrideBasePrompt: req.OverrideBasePrompt,
|
||||||
@@ -682,7 +684,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
err = s.store.Trader().Create(traderRecord)
|
err = s.store.Trader().Create(traderRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to create trader: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
||||||
@@ -732,7 +734,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
|
|
||||||
var req UpdateTraderRequest
|
var req UpdateTraderRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,11 +781,13 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
|
|
||||||
// Set scan interval, allow updates
|
// Set scan interval, allow updates
|
||||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||||
|
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
||||||
if scanIntervalMinutes <= 0 {
|
if scanIntervalMinutes <= 0 {
|
||||||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
||||||
} else if scanIntervalMinutes < 3 {
|
} else if scanIntervalMinutes < 3 {
|
||||||
scanIntervalMinutes = 3
|
scanIntervalMinutes = 3
|
||||||
}
|
}
|
||||||
|
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
||||||
|
|
||||||
// Set system prompt template
|
// Set system prompt template
|
||||||
systemPromptTemplate := req.SystemPromptTemplate
|
systemPromptTemplate := req.SystemPromptTemplate
|
||||||
@@ -818,16 +822,26 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|||||||
IsRunning: existingTrader.IsRunning, // Keep original value
|
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
|
// Update database
|
||||||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, req.StrategyID=%s",
|
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
||||||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, req.StrategyID)
|
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
||||||
err = s.store.Trader().Update(traderRecord)
|
err = s.store.Trader().Update(traderRecord)
|
||||||
if err != nil {
|
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
|
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)
|
s.traderManager.RemoveTrader(traderID)
|
||||||
|
|
||||||
// Reload traders into memory with fresh config
|
// 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)
|
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)
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -854,7 +880,7 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
|
|||||||
// Delete from database
|
// Delete from database
|
||||||
err := s.store.Trader().Delete(userID, traderID)
|
err := s.store.Trader().Delete(userID, traderID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1012,14 +1038,14 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,14 +1070,14 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
||||||
if err != nil {
|
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
|
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)
|
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
||||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
@@ -1150,7 +1190,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
|||||||
|
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1198,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
|||||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||||
if balanceErr != nil {
|
if balanceErr != nil {
|
||||||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1302,7 +1342,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
|||||||
|
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1344,7 +1384,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
|||||||
|
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1428,9 +1468,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
|||||||
FilledQuantity: quantity,
|
FilledQuantity: quantity,
|
||||||
AvgFillPrice: exitPrice,
|
AvgFillPrice: exitPrice,
|
||||||
Commission: fee,
|
Commission: fee,
|
||||||
FilledAt: time.Now(),
|
FilledAt: time.Now().UTC().UnixMilli(),
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||||
@@ -1458,7 +1498,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0,
|
RealizedPnL: 0,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||||
@@ -1533,7 +1573,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0,
|
RealizedPnL: 0,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
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)
|
models, err := s.store.AIModel().List(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,15 +1720,28 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|||||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
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 {
|
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)
|
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||||
if err != nil {
|
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
|
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
|
// Reload all traders for this user to make new config take effect immediately
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1706,8 +1759,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
|||||||
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
||||||
exchanges, err := s.store.Exchange().List(userID)
|
exchanges, err := s.store.Exchange().List(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to get exchange configs: %v", err)
|
SafeInternalError(c, "Failed to get exchange configs", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get exchange configs: %v", err)})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1801,15 +1853,28 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
|||||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
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 {
|
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)
|
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 {
|
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
|
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
|
// Reload all traders for this user to make new config take effect immediately
|
||||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1910,7 +1975,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1953,7 +2018,7 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
|||||||
err = s.store.Exchange().Delete(userID, exchangeID)
|
err = s.store.Exchange().Delete(userID, exchangeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1966,7 +2031,7 @@ func (s *Server) handleTraderList(c *gin.Context) {
|
|||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
traders, err := s.store.Trader().List(userID)
|
traders, err := s.store.Trader().List(userID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2019,7 +2084,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|||||||
|
|
||||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Failed to get trader config: %v", err)})
|
SafeNotFound(c, "Trader config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
traderConfig := fullCfg.Trader
|
traderConfig := fullCfg.Trader
|
||||||
@@ -2050,7 +2115,7 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|||||||
"custom_prompt": traderConfig.CustomPrompt,
|
"custom_prompt": traderConfig.CustomPrompt,
|
||||||
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
||||||
"is_cross_margin": traderConfig.IsCrossMargin,
|
"is_cross_margin": traderConfig.IsCrossMargin,
|
||||||
"use_coin_pool": traderConfig.UseCoinPool,
|
"use_ai500": traderConfig.UseAI500,
|
||||||
"use_oi_top": traderConfig.UseOITop,
|
"use_oi_top": traderConfig.UseOITop,
|
||||||
"is_running": isRunning,
|
"is_running": isRunning,
|
||||||
}
|
}
|
||||||
@@ -2062,13 +2127,13 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|||||||
func (s *Server) handleStatus(c *gin.Context) {
|
func (s *Server) handleStatus(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2080,23 +2145,20 @@ func (s *Server) handleStatus(c *gin.Context) {
|
|||||||
func (s *Server) handleAccount(c *gin.Context) {
|
func (s *Server) handleAccount(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
||||||
account, err := trader.GetAccountInfo()
|
account, err := trader.GetAccountInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to get account info [%s]: %v", trader.GetName(), err)
|
SafeInternalError(c, "Get account info", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("Failed to get account info: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2113,21 +2175,19 @@ func (s *Server) handleAccount(c *gin.Context) {
|
|||||||
func (s *Server) handlePositions(c *gin.Context) {
|
func (s *Server) handlePositions(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
positions, err := trader.GetPositions()
|
positions, err := trader.GetPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get positions", err)
|
||||||
"error": fmt.Sprintf("Failed to get position list: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2138,13 +2198,13 @@ func (s *Server) handlePositions(c *gin.Context) {
|
|||||||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2165,9 +2225,7 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
|||||||
// Get closed positions
|
// Get closed positions
|
||||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get position history", err)
|
||||||
"error": fmt.Sprintf("Failed to get position history: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2192,13 +2250,13 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
|||||||
func (s *Server) handleTrades(c *gin.Context) {
|
func (s *Server) handleTrades(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2224,9 +2282,7 @@ func (s *Server) handleTrades(c *gin.Context) {
|
|||||||
|
|
||||||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get trades", err)
|
||||||
"error": fmt.Sprintf("Failed to get trades: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2249,13 +2305,13 @@ func (s *Server) handleTrades(c *gin.Context) {
|
|||||||
func (s *Server) handleOrders(c *gin.Context) {
|
func (s *Server) handleOrders(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2280,30 +2336,14 @@ func (s *Server) handleOrders(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all orders for this trader
|
// Get orders with filters applied at database level
|
||||||
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
|
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get orders", err)
|
||||||
"error": fmt.Sprintf("Failed to get orders: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by symbol and status if specified
|
c.JSON(http.StatusOK, orders)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleOrderFills Order fill details (all fills for a specific order)
|
// 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)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2336,15 +2376,47 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
|||||||
// Get fills for this order
|
// Get fills for this order
|
||||||
fills, err := store.Order().GetOrderFills(orderID)
|
fills, err := store.Order().GetOrderFills(orderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get order fills", err)
|
||||||
"error": fmt.Sprintf("Failed to get order fills: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, fills)
|
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)
|
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||||||
func (s *Server) handleKlines(c *gin.Context) {
|
func (s *Server) handleKlines(c *gin.Context) {
|
||||||
// Get query parameters
|
// Get query parameters
|
||||||
@@ -2376,30 +2448,21 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
// US Stocks via Alpaca
|
// US Stocks via Alpaca
|
||||||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err)
|
SafeInternalError(c, "Get klines from Alpaca", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "forex", "metals":
|
case "forex", "metals":
|
||||||
// Forex and Metals via Twelve Data
|
// Forex and Metals via Twelve Data
|
||||||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err)
|
SafeInternalError(c, "Get klines from TwelveData", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
||||||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err)
|
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -2407,10 +2470,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
|||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
|
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2728,22 +2788,20 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
|||||||
func (s *Server) handleDecisions(c *gin.Context) {
|
func (s *Server) handleDecisions(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all historical decision records (unlimited)
|
// Get all historical decision records (unlimited)
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get decision log", err)
|
||||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2754,13 +2812,13 @@ func (s *Server) handleDecisions(c *gin.Context) {
|
|||||||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2777,9 +2835,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
|||||||
|
|
||||||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get decision log", err)
|
||||||
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2796,21 +2852,19 @@ func (s *Server) handleLatestDecisions(c *gin.Context) {
|
|||||||
func (s *Server) handleStatistics(c *gin.Context) {
|
func (s *Server) handleStatistics(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trader, err := s.traderManager.GetTrader(traderID)
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
SafeNotFound(c, "Trader")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get statistics", err)
|
||||||
"error": fmt.Sprintf("Failed to get statistics: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2829,9 +2883,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
|||||||
|
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
competition, err := s.traderManager.GetCompetitionData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get competition data", err)
|
||||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2843,7 +2895,7 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
|||||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid trader ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2851,9 +2903,7 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
|||||||
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
||||||
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get historical data", err)
|
||||||
"error": fmt.Sprintf("Failed to get historical data: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2931,7 +2981,8 @@ func (s *Server) authMiddleware() gin.HandlerFunc {
|
|||||||
// Validate JWT token
|
// Validate JWT token
|
||||||
claims, err := auth.ValidateJWT(tokenString)
|
claims, err := auth.ValidateJWT(tokenString)
|
||||||
if err != nil {
|
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()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2979,7 +3030,44 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
return
|
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
|
maxUsers := config.Get().MaxUsers
|
||||||
if maxUsers > 0 {
|
if maxUsers > 0 {
|
||||||
userCount, err := s.store.User().Count()
|
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
|
// Generate password hash
|
||||||
passwordHash, err := auth.HashPassword(req.Password)
|
passwordHash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3036,7 +3107,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
|||||||
|
|
||||||
err = s.store.User().Create(user)
|
err = s.store.User().Create(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
SafeInternalError(c, "Failed to create user", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3059,14 +3130,14 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user information
|
// Get user information
|
||||||
user, err := s.store.User().GetByID(req.UserID)
|
user, err := s.store.User().GetByID(req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
SafeNotFound(c, "User")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3112,7 +3183,7 @@ func (s *Server) handleLogin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3131,10 +3202,15 @@ func (s *Server) handleLogin(c *gin.Context) {
|
|||||||
|
|
||||||
// Check if OTP is verified
|
// Check if OTP is verified
|
||||||
if !user.OTPVerified {
|
if !user.OTPVerified {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
// Return OTP info so user can complete setup
|
||||||
"error": "Account has not completed OTP setup",
|
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user_id": user.ID,
|
"user_id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"otp_secret": user.OTPSecret,
|
||||||
|
"qr_code_url": qrCodeURL,
|
||||||
"requires_otp_setup": true,
|
"requires_otp_setup": true,
|
||||||
|
"message": "Please complete OTP setup first",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3156,14 +3232,14 @@ func (s *Server) handleVerifyOTP(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user information
|
// Get user information
|
||||||
user, err := s.store.User().GetByID(req.UserID)
|
user, err := s.store.User().GetByID(req.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
SafeNotFound(c, "User")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3197,7 +3273,7 @@ func (s *Server) handleResetPassword(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
SafeBadRequest(c, "Invalid request parameters")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3326,9 +3402,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
|||||||
// Get trader information from all users
|
// Get trader information from all users
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
competition, err := s.traderManager.GetCompetitionData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get trader list", err)
|
||||||
"error": fmt.Sprintf("Failed to get trader list: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3371,9 +3445,7 @@ func (s *Server) handlePublicTraderList(c *gin.Context) {
|
|||||||
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
||||||
competition, err := s.traderManager.GetCompetitionData()
|
competition, err := s.traderManager.GetCompetitionData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get competition data", err)
|
||||||
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3384,9 +3456,7 @@ func (s *Server) handlePublicCompetition(c *gin.Context) {
|
|||||||
func (s *Server) handleTopTraders(c *gin.Context) {
|
func (s *Server) handleTopTraders(c *gin.Context) {
|
||||||
topTraders, err := s.traderManager.GetTopTradersData()
|
topTraders, err := s.traderManager.GetTopTradersData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get top traders data", err)
|
||||||
"error": fmt.Sprintf("Failed to get top 10 trader data: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3409,9 +3479,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
|||||||
// If no trader_ids specified, return historical data for top 5
|
// If no trader_ids specified, return historical data for top 5
|
||||||
topTraders, err := s.traderManager.GetTopTradersData()
|
topTraders, err := s.traderManager.GetTopTradersData()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
SafeInternalError(c, "Get top traders", err)
|
||||||
"error": fmt.Sprintf("Failed to get top 5 traders: %v", err),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3506,7 +3574,8 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
|||||||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
||||||
}
|
}
|
||||||
if err != nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -19,11 +19,11 @@ import (
|
|||||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||||
var warnings []string
|
var warnings []string
|
||||||
|
|
||||||
// Validate quant data URL if enabled
|
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||||
if config.Indicators.EnableQuantData && config.Indicators.QuantDataAPIURL != "" {
|
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||||
if !strings.Contains(config.Indicators.QuantDataAPIURL, "{symbol}") {
|
config.Indicators.EnableNetFlowRanking || config.Indicators.EnablePriceRanking) &&
|
||||||
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.")
|
config.Indicators.NofxOSAPIKey == "" {
|
||||||
}
|
warnings = append(warnings, "NofxOS API key is not configured. NofxOS data sources may not work properly.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
@@ -33,7 +33,7 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
|||||||
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||||
strategies, err := s.store.Strategy().ListPublic()
|
strategies, err := s.store.Strategy().ListPublic()
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
|||||||
|
|
||||||
strategies, err := s.store.Strategy().List(userID)
|
strategies, err := s.store.Strategy().List(userID)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,14 +151,14 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize configuration
|
// Serialize configuration
|
||||||
configJSON, err := json.Marshal(req.Config)
|
configJSON, err := json.Marshal(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
SafeInternalError(c, "Serialize configuration", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +221,14 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serialize configuration
|
// Serialize configuration
|
||||||
configJSON, err := json.Marshal(req.Config)
|
configJSON, err := json.Marshal(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize configuration"})
|
SafeInternalError(c, "Serialize configuration", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Update(strategy); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ func (s *Server) handleDeleteStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().Delete(userID, strategyID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +287,7 @@ func (s *Server) handleActivateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Strategy().SetActive(userID, strategyID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,13 +309,13 @@ func (s *Server) handleDuplicateStrategy(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newID := uuid.New().String()
|
newID := uuid.New().String()
|
||||||
if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +396,7 @@ func (s *Server) handlePreviewPrompt(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create strategy engine to build prompt
|
// 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)
|
// Build system prompt (using built-in method from strategy engine)
|
||||||
systemPrompt := engine.BuildSystemPrompt(
|
systemPrompt := engine.BuildSystemPrompt(
|
||||||
@@ -433,7 +433,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,13 +442,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create strategy engine to build prompt
|
// Create strategy engine to build prompt
|
||||||
engine := decision.NewStrategyEngine(&req.Config)
|
engine := kernel.NewStrategyEngine(&req.Config)
|
||||||
|
|
||||||
// Get candidate coins
|
// Get candidate coins
|
||||||
candidates, err := engine.GetCandidateCoins()
|
candidates, err := engine.GetCandidateCoins()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Errorf("[API Error] Failed to get candidate coins: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to get candidate coins: " + err.Error(),
|
"error": "Failed to get candidate coins",
|
||||||
"ai_response": "",
|
"ai_response": "",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -502,12 +503,18 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
// Fetch OI ranking data (market-wide position changes)
|
// Fetch OI ranking data (market-wide position changes)
|
||||||
oiRankingData := engine.FetchOIRankingData()
|
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)
|
// 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"),
|
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||||
RuntimeMinutes: 0,
|
RuntimeMinutes: 0,
|
||||||
CallCount: 1,
|
CallCount: 1,
|
||||||
Account: decision.AccountInfo{
|
Account: kernel.AccountInfo{
|
||||||
TotalEquity: 1000.0,
|
TotalEquity: 1000.0,
|
||||||
AvailableBalance: 1000.0,
|
AvailableBalance: 1000.0,
|
||||||
UnrealizedPnL: 0,
|
UnrealizedPnL: 0,
|
||||||
@@ -517,12 +524,14 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
|||||||
MarginUsedPct: 0,
|
MarginUsedPct: 0,
|
||||||
PositionCount: 0,
|
PositionCount: 0,
|
||||||
},
|
},
|
||||||
Positions: []decision.PositionInfo{},
|
Positions: []kernel.PositionInfo{},
|
||||||
CandidateCoins: candidates,
|
CandidateCoins: candidates,
|
||||||
PromptVariant: req.PromptVariant,
|
PromptVariant: req.PromptVariant,
|
||||||
MarketDataMap: marketDataMap,
|
MarketDataMap: marketDataMap,
|
||||||
QuantDataMap: quantDataMap,
|
QuantDataMap: quantDataMap,
|
||||||
OIRankingData: oiRankingData,
|
OIRankingData: oiRankingData,
|
||||||
|
NetFlowRankingData: netFlowRankingData,
|
||||||
|
PriceRankingData: priceRankingData,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build System Prompt
|
// 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)
|
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||||
notional := execPrice * quantity
|
closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
|
||||||
closingFee := notional * acc.feeRate
|
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
|
closePortion := quantity / pos.Quantity
|
||||||
openingFeePortion := pos.AccumulatedFee * closePortion
|
openingFeePortion := pos.AccumulatedFee * closePortion
|
||||||
totalFee := closingFee + openingFeePortion
|
totalFee := closingFee + openingFeePortion
|
||||||
@@ -133,13 +133,17 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
|
|||||||
realized := realizedPnL(pos, quantity, execPrice)
|
realized := realizedPnL(pos, quantity, execPrice)
|
||||||
|
|
||||||
marginPortion := pos.Margin * closePortion
|
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
|
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
|
||||||
acc.cash += marginPortion + realized - closingFee
|
acc.cash += marginPortion + realized - closingFee
|
||||||
// But for realized P&L tracking, we include both fees
|
// But for realized P&L tracking, we include both fees
|
||||||
acc.realizedPnL += realized - totalFee
|
acc.realizedPnL += realized - totalFee
|
||||||
|
|
||||||
pos.Quantity -= quantity
|
pos.Quantity -= quantity
|
||||||
pos.Notional -= notional
|
pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
|
||||||
pos.Margin -= marginPortion
|
pos.Margin -= marginPortion
|
||||||
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ type cachedDecision struct {
|
|||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
PromptVariant string `json:"prompt_variant"`
|
PromptVariant string `json:"prompt_variant"`
|
||||||
Timestamp int64 `json:"ts"`
|
Timestamp int64 `json:"ts"`
|
||||||
Decision *decision.FullDecision `json:"decision"`
|
Decision *kernel.FullDecision `json:"decision"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AICache persists AI decisions for repeated backtesting or replay.
|
// AICache persists AI decisions for repeated backtesting or replay.
|
||||||
@@ -67,7 +67,7 @@ func (c *AICache) Path() string {
|
|||||||
return c.path
|
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 == "" {
|
if c == nil || key == "" {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func (c *AICache) Get(key string) (*decision.FullDecision, bool) {
|
|||||||
return cloneDecision(entry.Decision), true
|
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 {
|
if c == nil || key == "" || decision == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ func (c *AICache) save() error {
|
|||||||
return writeFileAtomic(c.path, data, 0o644)
|
return writeFileAtomic(c.path, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
func cloneDecision(src *kernel.FullDecision) *kernel.FullDecision {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -117,14 +117,14 @@ func cloneDecision(src *decision.FullDecision) *decision.FullDecision {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var dst decision.FullDecision
|
var dst kernel.FullDecision
|
||||||
if err := json.Unmarshal(data, &dst); err != nil {
|
if err := json.Unmarshal(data, &dst); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &dst
|
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 {
|
if ctx == nil {
|
||||||
return "", fmt.Errorf("context is 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"`
|
Variant string `json:"variant"`
|
||||||
Timestamp int64 `json:"ts"`
|
Timestamp int64 `json:"ts"`
|
||||||
CurrentTime string `json:"current_time"`
|
CurrentTime string `json:"current_time"`
|
||||||
Account decision.AccountInfo `json:"account"`
|
Account kernel.AccountInfo `json:"account"`
|
||||||
Positions []decision.PositionInfo `json:"positions"`
|
Positions []kernel.PositionInfo `json:"positions"`
|
||||||
CandidateCoins []decision.CandidateCoin `json:"candidate_coins"`
|
CandidateCoins []kernel.CandidateCoin `json:"candidate_coins"`
|
||||||
MarketData map[string]market.Data `json:"market"`
|
MarketData map[string]market.Data `json:"market"`
|
||||||
MarginUsedPct float64 `json:"margin_used_pct"`
|
MarginUsedPct float64 `json:"margin_used_pct"`
|
||||||
Runtime int `json:"runtime_minutes"`
|
Runtime int `json:"runtime_minutes"`
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
|||||||
if len(cfg.Symbols) > 0 {
|
if len(cfg.Symbols) > 0 {
|
||||||
result.CoinSource.SourceType = "static"
|
result.CoinSource.SourceType = "static"
|
||||||
result.CoinSource.StaticCoins = cfg.Symbols
|
result.CoinSource.StaticCoins = cfg.Symbols
|
||||||
result.CoinSource.UseCoinPool = false
|
result.CoinSource.UseAI500 = false
|
||||||
result.CoinSource.UseOITop = false
|
result.CoinSource.UseOITop = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,12 +241,12 @@ func (cfg *BacktestConfig) ToStrategyConfig() *store.StrategyConfig {
|
|||||||
|
|
||||||
return &store.StrategyConfig{
|
return &store.StrategyConfig{
|
||||||
CoinSource: store.CoinSourceConfig{
|
CoinSource: store.CoinSourceConfig{
|
||||||
SourceType: "static",
|
SourceType: "static",
|
||||||
StaticCoins: cfg.Symbols,
|
StaticCoins: cfg.Symbols,
|
||||||
UseCoinPool: false,
|
UseAI500: false,
|
||||||
CoinPoolLimit: len(cfg.Symbols),
|
AI500Limit: len(cfg.Symbols),
|
||||||
UseOITop: false,
|
UseOITop: false,
|
||||||
OITopLimit: 0,
|
OITopLimit: 0,
|
||||||
},
|
},
|
||||||
Indicators: store.IndicatorConfig{
|
Indicators: store.IndicatorConfig{
|
||||||
Klines: store.KlineConfig{
|
Klines: store.KlineConfig{
|
||||||
|
|||||||
@@ -124,11 +124,23 @@ func (df *DataFeed) DecisionBarCount() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
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]
|
return df.decisionTimes[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
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 {
|
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||||
return series.closeTimes[i] > ts
|
return series.closeTimes[i] > ts
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -91,8 +91,13 @@ func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
|||||||
return maxDD
|
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 {
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,34 +113,42 @@ func sharpeRatio(points []EquityPoint) float64 {
|
|||||||
returns = append(returns, ret)
|
returns = append(returns, ret)
|
||||||
prev = curr
|
prev = curr
|
||||||
}
|
}
|
||||||
if len(returns) == 0 {
|
if len(returns) < minDataPoints-1 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate mean return
|
||||||
mean := 0.0
|
mean := 0.0
|
||||||
for _, r := range returns {
|
for _, r := range returns {
|
||||||
mean += r
|
mean += r
|
||||||
}
|
}
|
||||||
mean /= float64(len(returns))
|
mean /= float64(len(returns))
|
||||||
|
|
||||||
|
// Calculate sample variance (using n-1 for unbiased estimator)
|
||||||
variance := 0.0
|
variance := 0.0
|
||||||
for _, r := range returns {
|
for _, r := range returns {
|
||||||
diff := r - mean
|
diff := r - mean
|
||||||
variance += diff * diff
|
variance += diff * diff
|
||||||
}
|
}
|
||||||
variance /= float64(len(returns))
|
if len(returns) > 1 {
|
||||||
|
variance /= float64(len(returns) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
std := math.Sqrt(variance)
|
std := math.Sqrt(variance)
|
||||||
if std == 0 {
|
if std < 1e-10 {
|
||||||
if mean > 0 {
|
// Zero or near-zero volatility - return 0 instead of infinity/NaN
|
||||||
return 999
|
|
||||||
}
|
|
||||||
if mean < 0 {
|
|
||||||
return -999
|
|
||||||
}
|
|
||||||
return 0
|
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) {
|
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||||
@@ -189,7 +202,8 @@ func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
|||||||
if totalLossAmount > 0 {
|
if totalLossAmount > 0 {
|
||||||
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||||
} else if totalWinAmount > 0 {
|
} else if totalWinAmount > 0 {
|
||||||
metrics.ProfitFactor = 999
|
// No losses but have wins - use a high but reasonable cap
|
||||||
|
metrics.ProfitFactor = 100.0
|
||||||
}
|
}
|
||||||
|
|
||||||
bestSymbol := ""
|
bestSymbol := ""
|
||||||
|
|||||||
@@ -2,15 +2,39 @@ package backtest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var persistenceDB *sql.DB
|
var persistenceDB *sql.DB
|
||||||
|
var dbIsPostgres bool
|
||||||
|
|
||||||
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
// 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) {
|
func UseDatabase(db *sql.DB) {
|
||||||
persistenceDB = 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 {
|
func usingDB() bool {
|
||||||
return persistenceDB != nil
|
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,
|
RunStateFailed,
|
||||||
RunStateLiquidated,
|
RunStateLiquidated,
|
||||||
}
|
}
|
||||||
query := `
|
query := convertQuery(`
|
||||||
SELECT run_id FROM backtest_runs
|
SELECT run_id FROM backtest_runs
|
||||||
WHERE state IN (?, ?, ?, ?)
|
WHERE state IN (?, ?, ?, ?)
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
OFFSET ?
|
OFFSET ?
|
||||||
`
|
`)
|
||||||
rows, err := persistenceDB.Query(query,
|
rows, err := persistenceDB.Query(query,
|
||||||
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
@@ -34,7 +34,7 @@ type Runner struct {
|
|||||||
cfg BacktestConfig
|
cfg BacktestConfig
|
||||||
feed *DataFeed
|
feed *DataFeed
|
||||||
account *BacktestAccount
|
account *BacktestAccount
|
||||||
strategyEngine *decision.StrategyEngine
|
strategyEngine *kernel.StrategyEngine
|
||||||
|
|
||||||
decisionLogDir string
|
decisionLogDir string
|
||||||
mcpClient mcp.AIClient
|
mcpClient mcp.AIClient
|
||||||
@@ -60,8 +60,9 @@ type Runner struct {
|
|||||||
aiCache *AICache
|
aiCache *AICache
|
||||||
cachePath string
|
cachePath string
|
||||||
|
|
||||||
lockInfo *RunLockInfo
|
lockInfo *RunLockInfo
|
||||||
lockStop chan struct{}
|
lockStop chan struct{}
|
||||||
|
lockStopOnce sync.Once // Ensures lockStop is closed only once
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner constructs a backtest runner.
|
// 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
|
// Create strategy engine from backtest config for unified prompt generation
|
||||||
strategyConfig := cfg.ToStrategyConfig()
|
strategyConfig := cfg.ToStrategyConfig()
|
||||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||||
|
|
||||||
r := &Runner{
|
r := &Runner{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
@@ -175,10 +176,12 @@ func (r *Runner) lockHeartbeatLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) releaseLock() {
|
func (r *Runner) releaseLock() {
|
||||||
if r.lockStop != nil {
|
// Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
|
||||||
close(r.lockStop)
|
r.lockStopOnce.Do(func() {
|
||||||
r.lockStop = nil
|
if r.lockStop != nil {
|
||||||
}
|
close(r.lockStop)
|
||||||
|
}
|
||||||
|
})
|
||||||
if err := deleteRunLock(r.cfg.RunID); err != nil {
|
if err := deleteRunLock(r.cfg.RunID); err != nil {
|
||||||
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
|
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
|
||||||
}
|
}
|
||||||
@@ -297,15 +300,18 @@ func (r *Runner) stepOnce() error {
|
|||||||
if shouldDecide {
|
if shouldDecide {
|
||||||
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
|
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rec.Success = false
|
// Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
|
||||||
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
if rec != nil {
|
||||||
_ = r.logDecision(rec)
|
rec.Success = false
|
||||||
|
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
||||||
|
_ = r.logDecision(rec)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
record = rec
|
record = rec
|
||||||
|
|
||||||
var (
|
var (
|
||||||
fullDecision *decision.FullDecision
|
fullDecision *kernel.FullDecision
|
||||||
fromCache bool
|
fromCache bool
|
||||||
cacheKey string
|
cacheKey string
|
||||||
)
|
)
|
||||||
@@ -470,7 +476,7 @@ func (r *Runner) stepOnce() error {
|
|||||||
return nil
|
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)
|
equity, unrealized, _ := r.account.TotalEquity(priceMap)
|
||||||
available := r.account.Cash()
|
available := r.account.Cash()
|
||||||
marginUsed := r.totalMarginUsed()
|
marginUsed := r.totalMarginUsed()
|
||||||
@@ -479,7 +485,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
|||||||
marginPct = (marginUsed / equity) * 100
|
marginPct = (marginUsed / equity) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
accountInfo := decision.AccountInfo{
|
accountInfo := kernel.AccountInfo{
|
||||||
TotalEquity: equity,
|
TotalEquity: equity,
|
||||||
AvailableBalance: available,
|
AvailableBalance: available,
|
||||||
TotalPnL: equity - r.account.InitialBalance(),
|
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()
|
candidateCoins, err := r.strategyEngine.GetCandidateCoins()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback to simple list if strategy engine fails
|
// 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 {
|
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)
|
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"),
|
CurrentTime: time.UnixMilli(ts).UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||||
RuntimeMinutes: runtime,
|
RuntimeMinutes: runtime,
|
||||||
CallCount: callCount,
|
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)
|
// Fetch quantitative data if enabled in strategy (uses current data as approximation)
|
||||||
strategyConfig := r.strategyEngine.GetConfig()
|
strategyConfig := r.strategyEngine.GetConfig()
|
||||||
if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" {
|
if strategyConfig.Indicators.EnableQuantData {
|
||||||
// Collect symbols to query (candidate coins + position coins)
|
// Collect symbols to query (candidate coins + position coins)
|
||||||
symbolSet := make(map[string]bool)
|
symbolSet := make(map[string]bool)
|
||||||
for _, sym := range r.cfg.Symbols {
|
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{
|
record := &store.DecisionRecord{
|
||||||
AccountState: store.AccountSnapshot{
|
AccountState: store.AccountSnapshot{
|
||||||
TotalBalance: accountInfo.TotalEquity,
|
TotalBalance: accountInfo.TotalEquity,
|
||||||
@@ -566,7 +590,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da
|
|||||||
return ctx, record, nil
|
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.InputPrompt = full.UserPrompt
|
||||||
record.CoTTrace = full.CoTTrace
|
record.CoTTrace = full.CoTTrace
|
||||||
if len(full.Decisions) > 0 {
|
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
|
var lastErr error
|
||||||
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
for attempt := 0; attempt < aiDecisionMaxRetries; attempt++ {
|
||||||
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
|
// Use GetFullDecisionWithStrategy with the pre-configured strategy engine
|
||||||
// This ensures backtest uses the same unified prompt generation as live trading
|
// This ensures backtest uses the same unified prompt generation as live trading
|
||||||
fd, err := decision.GetFullDecisionWithStrategy(
|
fd, err := kernel.GetFullDecisionWithStrategy(
|
||||||
ctx,
|
ctx,
|
||||||
r.mcpClient,
|
r.mcpClient,
|
||||||
r.strategyEngine,
|
r.strategyEngine,
|
||||||
@@ -597,8 +621,12 @@ func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecisio
|
|||||||
return nil, lastErr
|
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
|
symbol := dec.Symbol
|
||||||
|
if symbol == "" {
|
||||||
|
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
|
||||||
|
}
|
||||||
|
|
||||||
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
|
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
|
||||||
actionRecord := store.DecisionAction{
|
actionRecord := store.DecisionAction{
|
||||||
Action: dec.Action,
|
Action: dec.Action,
|
||||||
@@ -607,9 +635,13 @@ func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]floa
|
|||||||
Timestamp: time.UnixMilli(ts).UTC(),
|
Timestamp: time.UnixMilli(ts).UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
basePrice := priceMap[symbol]
|
if priceMap == nil {
|
||||||
if basePrice <= 0 {
|
return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
|
||||||
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s", symbol)
|
}
|
||||||
|
|
||||||
|
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)
|
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()
|
snapshot := r.snapshotState()
|
||||||
equity := snapshot.Equity
|
equity := snapshot.Equity
|
||||||
if equity <= 0 {
|
if equity <= 0 {
|
||||||
@@ -770,6 +805,13 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
|
|||||||
sizeUSD = maxPositionValue
|
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
|
qty := sizeUSD / price
|
||||||
if qty < 0 {
|
if qty < 0 {
|
||||||
qty = 0
|
qty = 0
|
||||||
@@ -777,7 +819,7 @@ func (r *Runner) determineQuantity(dec decision.Decision, price float64) float64
|
|||||||
return qty
|
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() {
|
for _, pos := range r.account.Positions() {
|
||||||
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
|
if pos.Symbol == strings.ToUpper(symbol) && pos.Side == side {
|
||||||
return pos.Quantity
|
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 {
|
func (r *Runner) resolveLeverage(requested int, symbol string) int {
|
||||||
if requested > 0 {
|
|
||||||
return requested
|
|
||||||
}
|
|
||||||
sym := strings.ToUpper(symbol)
|
sym := strings.ToUpper(symbol)
|
||||||
if sym == "BTCUSDT" || sym == "ETHUSDT" {
|
isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
|
||||||
if r.cfg.Leverage.BTCETHLeverage > 0 {
|
|
||||||
return r.cfg.Leverage.BTCETHLeverage
|
// 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 {
|
} else {
|
||||||
if r.cfg.Leverage.AltcoinLeverage > 0 {
|
maxLeverage = r.cfg.Leverage.AltcoinLeverage
|
||||||
return 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 {
|
func (r *Runner) remainingPosition(symbol, side string) float64 {
|
||||||
@@ -831,20 +890,26 @@ func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.Position
|
|||||||
return list
|
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()
|
positions := r.account.Positions()
|
||||||
list := make([]decision.PositionInfo, 0, len(positions))
|
list := make([]kernel.PositionInfo, 0, len(positions))
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
price := priceMap[pos.Symbol]
|
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,
|
Symbol: pos.Symbol,
|
||||||
Side: pos.Side,
|
Side: pos.Side,
|
||||||
EntryPrice: pos.EntryPrice,
|
EntryPrice: pos.EntryPrice,
|
||||||
MarkPrice: price,
|
MarkPrice: price,
|
||||||
Quantity: pos.Quantity,
|
Quantity: pos.Quantity,
|
||||||
Leverage: pos.Leverage,
|
Leverage: pos.Leverage,
|
||||||
UnrealizedPnL: unrealizedPnL(pos, price),
|
UnrealizedPnL: pnl,
|
||||||
UnrealizedPnLPct: 0,
|
UnrealizedPnLPct: pnlPct,
|
||||||
LiquidationPrice: pos.LiquidationPrice,
|
LiquidationPrice: pos.LiquidationPrice,
|
||||||
MarginUsed: pos.Margin,
|
MarginUsed: pos.Margin,
|
||||||
UpdateTime: time.Now().UnixMilli(),
|
UpdateTime: time.Now().UnixMilli(),
|
||||||
@@ -1416,7 +1481,7 @@ func snapshotsToMap(snaps []PositionSnapshot) map[string]PositionSnapshot {
|
|||||||
return positions
|
return positions
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision {
|
func sortDecisionsByPriority(decisions []kernel.Decision) []kernel.Decision {
|
||||||
if len(decisions) <= 1 {
|
if len(decisions) <= 1 {
|
||||||
return decisions
|
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)
|
copy(result, decisions)
|
||||||
|
|
||||||
sort.Slice(result, func(i, j int) bool {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = persistenceDB.Exec(`
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||||
`, runID, data)
|
`), runID, data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||||
var payload []byte
|
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 err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, os.ErrNotExist
|
return nil, os.ErrNotExist
|
||||||
@@ -57,25 +57,25 @@ func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
|||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = "default"
|
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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(run_id) DO NOTHING
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = persistenceDB.Exec(`
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
UPDATE backtest_runs
|
UPDATE backtest_runs
|
||||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE run_id = ?
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||||
var payload []byte
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -96,18 +96,18 @@ func saveRunMetadataDB(meta *RunMetadata) error {
|
|||||||
if userID == "" {
|
if userID == "" {
|
||||||
userID = "default"
|
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)
|
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(run_id) DO NOTHING
|
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
|
return err
|
||||||
}
|
}
|
||||||
_, err := persistenceDB.Exec(`
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
UPDATE backtest_runs
|
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 = ?
|
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 = ?
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +128,10 @@ func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
|||||||
createdISO string
|
createdISO string
|
||||||
updatedISO 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
|
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 = ?
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -183,18 +183,18 @@ func loadRunIDsDB() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendEquityPointDB(runID string, point EquityPoint) 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)
|
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
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
|
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||||
`, runID)
|
`), runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -211,18 +211,18 @@ func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendTradeEventDB(runID string, event TradeEvent) 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)
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
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
|
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
|
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||||
`, runID)
|
`), runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -243,17 +243,17 @@ func saveMetricsDB(runID string, metrics *Metrics) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = persistenceDB.Exec(`
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||||
`, runID, data)
|
`), runID, data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadMetricsDB(runID string) (*Metrics, error) {
|
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||||
var payload []byte
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -265,22 +265,21 @@ func loadMetricsDB(runID string) (*Metrics, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveProgressDB(runID string, payload progressPayload) error {
|
func saveProgressDB(runID string, payload progressPayload) error {
|
||||||
_, err := persistenceDB.Exec(`
|
_, err := persistenceDB.Exec(convertQuery(`
|
||||||
UPDATE backtest_runs
|
UPDATE backtest_runs
|
||||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||||
WHERE run_id = ?
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||||
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
|
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if cycle > 0 {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -308,20 +307,20 @@ func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = persistenceDB.Exec(`
|
_, err = persistenceDB.Exec(convertQuery(`
|
||||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
`, runID, record.CycleNumber, data)
|
`), runID, record.CycleNumber, data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||||
rows, err := persistenceDB.Query(`
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
SELECT payload FROM backtest_decisions
|
SELECT payload FROM backtest_decisions
|
||||||
WHERE run_id = ?
|
WHERE run_id = ?
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`, runID, limit, offset)
|
`), runID, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||||
rows, err := persistenceDB.Query(`
|
rows, err := persistenceDB.Query(convertQuery(`
|
||||||
SELECT id, cycle, payload FROM backtest_decisions
|
SELECT id, cycle, payload FROM backtest_decisions
|
||||||
WHERE run_id = ? ORDER BY id ASC
|
WHERE run_id = ? ORDER BY id ASC
|
||||||
`, runID)
|
`), runID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -494,6 +493,6 @@ func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteRunDB(runID string) 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
|
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"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// TraderExecutor interface for executing trades
|
// TraderExecutor interface for executing trades
|
||||||
type TraderExecutor interface {
|
type TraderExecutor interface {
|
||||||
ExecuteDecision(decision *decision.Decision) error
|
ExecuteDecision(decision *kernel.Decision) error
|
||||||
GetBalance() (map[string]interface{}, error)
|
GetBalance() (map[string]interface{}, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strate
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Create strategy engine for building context
|
// Create strategy engine for building context
|
||||||
strategyEngine := decision.NewStrategyEngine(strategyConfig)
|
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
|
||||||
|
|
||||||
// Build market context using strategy config
|
// Build market context using strategy config
|
||||||
ctx, err := e.buildMarketContext(session, strategyEngine)
|
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
|
// 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()
|
config := strategyEngine.GetConfig()
|
||||||
|
|
||||||
// Get candidate coins
|
// Get candidate coins
|
||||||
@@ -335,12 +335,18 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
|||||||
// Fetch OI ranking data (market-wide position changes)
|
// Fetch OI ranking data (market-wide position changes)
|
||||||
oiRankingData := strategyEngine.FetchOIRankingData()
|
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
|
// Build context
|
||||||
ctx := &decision.Context{
|
ctx := &kernel.Context{
|
||||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||||
RuntimeMinutes: 0,
|
RuntimeMinutes: 0,
|
||||||
CallCount: 1,
|
CallCount: 1,
|
||||||
Account: decision.AccountInfo{
|
Account: kernel.AccountInfo{
|
||||||
TotalEquity: 1000.0, // Simulated for debate
|
TotalEquity: 1000.0, // Simulated for debate
|
||||||
AvailableBalance: 1000.0,
|
AvailableBalance: 1000.0,
|
||||||
UnrealizedPnL: 0,
|
UnrealizedPnL: 0,
|
||||||
@@ -350,12 +356,14 @@ func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetail
|
|||||||
MarginUsedPct: 0,
|
MarginUsedPct: 0,
|
||||||
PositionCount: 0,
|
PositionCount: 0,
|
||||||
},
|
},
|
||||||
Positions: []decision.PositionInfo{},
|
Positions: []kernel.PositionInfo{},
|
||||||
CandidateCoins: candidates,
|
CandidateCoins: candidates,
|
||||||
PromptVariant: session.PromptVariant,
|
PromptVariant: session.PromptVariant,
|
||||||
MarketDataMap: marketDataMap,
|
MarketDataMap: marketDataMap,
|
||||||
QuantDataMap: quantDataMap,
|
QuantDataMap: quantDataMap,
|
||||||
OIRankingData: oiRankingData,
|
OIRankingData: oiRankingData,
|
||||||
|
NetFlowRankingData: netFlowRankingData,
|
||||||
|
PriceRankingData: priceRankingData,
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
@@ -539,7 +547,7 @@ func (e *DebateEngine) getParticipantResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// collectVotes collects final votes from all participants
|
// 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
|
var votes []*store.DebateVote
|
||||||
|
|
||||||
// Build voting context
|
// Build voting context
|
||||||
@@ -1009,7 +1017,7 @@ func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecuto
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create decision
|
// Create decision
|
||||||
tradeDecision := &decision.Decision{
|
tradeDecision := &kernel.Decision{
|
||||||
Symbol: session.Symbol,
|
Symbol: session.Symbol,
|
||||||
Action: action,
|
Action: action,
|
||||||
Leverage: session.FinalDecision.Leverage,
|
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 个币种
|
- **用途:** 获取 AI 评分最高的 N 个币种
|
||||||
- **标签:** `["ai500"]`
|
- **标签:** `["ai500"]`
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き 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 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
> **リスク警告**: このシステムは実験的です。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 デプロイ(推奨)
|
### オプション 1: Docker デプロイ(推奨)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 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 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
> **위험 경고**: 이 시스템은 실험적입니다. 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 배포 (권장)
|
### 옵션 1: Docker 배포 (권장)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||||
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
|
- **Панель реального времени**: Живые позиции, отслеживание 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 автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
|
> **Предупреждение о рисках**: Эта система экспериментальная. 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 развёртывание (рекомендуется)
|
### Вариант 1: Docker развёртывание (рекомендуется)
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||||
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
|
- **Панель реального часу**: Живі позиції, відстеження 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 автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
|
> **Попередження про ризики**: Ця система експериментальна. 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 розгортання (рекомендовано)
|
### Варіант 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
|
- **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
|
- **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ả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
|
## 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
|
## Bắt Đầu Nhanh
|
||||||
|
|
||||||
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
|
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||||
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
|
- **官方 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 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
||||||
|
|
||||||
## 开发者社区
|
## 开发者社区
|
||||||
@@ -42,19 +48,12 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 截图
|
## 开始之前
|
||||||
|
|
||||||
### 竞赛模式 - 实时 AI 对战
|
使用 NOFX 你需要准备:
|
||||||

|
|
||||||
*多 AI 排行榜,实时性能对比*
|
|
||||||
|
|
||||||
### 仪表板 - 市场图表视图
|
1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
|
||||||

|
2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek,性价比最高)
|
||||||
*专业交易仪表板,TradingView 风格图表*
|
|
||||||
|
|
||||||
### 策略工作室
|
|
||||||

|
|
||||||
*多数据源策略配置与 AI 测试*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,6 +66,7 @@
|
|||||||
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||||
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
|
||||||
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
|
||||||
|
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||||
|
|
||||||
### Perp-DEX (去中心化永续交易所)
|
### Perp-DEX (去中心化永续交易所)
|
||||||
|
|
||||||
@@ -92,9 +92,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
### 竞赛模式 - 实时 AI 对战
|
||||||
|

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

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

|
||||||
|
*多数据源策略配置与 AI 测试*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 一键安装 (推荐)
|
### 一键安装 (本地/服务器)
|
||||||
|
|
||||||
**Linux / macOS:**
|
**Linux / macOS:**
|
||||||
```bash
|
```bash
|
||||||
@@ -103,6 +119,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
|||||||
|
|
||||||
完成!打开浏览器访问 **http://127.0.0.1:3000**
|
完成!打开浏览器访问 **http://127.0.0.1:3000**
|
||||||
|
|
||||||
|
### 一键云部署 (Railway)
|
||||||
|
|
||||||
|
一键部署到 Railway - 无需自己搭建服务器:
|
||||||
|
|
||||||
|
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||||
|
|
||||||
|
部署后,Railway 会提供一个公网 URL 访问你的 NOFX 实例。
|
||||||
|
|
||||||
### Docker Compose (手动)
|
### Docker Compose (手动)
|
||||||
|
|
||||||
```bash
|
```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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/provider"
|
"nofx/provider/nofxos"
|
||||||
"nofx/security"
|
"nofx/security"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -119,8 +119,10 @@ type Context struct {
|
|||||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||||
OITopDataMap map[string]*OITopData `json:"-"`
|
OITopDataMap map[string]*OITopData `json:"-"`
|
||||||
QuantDataMap map[string]*QuantData `json:"-"`
|
QuantDataMap map[string]*QuantData `json:"-"`
|
||||||
OIRankingData *provider.OIRankingData `json:"-"` // Market-wide OI ranking data
|
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||||
BTCETHLeverage int `json:"-"`
|
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:"-"`
|
AltcoinLeverage int `json:"-"`
|
||||||
Timeframes []string `json:"-"`
|
Timeframes []string `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,8 @@ type Context struct {
|
|||||||
// Decision AI trading decision
|
// Decision AI trading decision
|
||||||
type Decision struct {
|
type Decision struct {
|
||||||
Symbol string `json:"symbol"`
|
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
|
// Opening position parameters
|
||||||
Leverage int `json:"leverage,omitempty"`
|
Leverage int `json:"leverage,omitempty"`
|
||||||
@@ -136,6 +139,12 @@ type Decision struct {
|
|||||||
StopLoss float64 `json:"stop_loss,omitempty"`
|
StopLoss float64 `json:"stop_loss,omitempty"`
|
||||||
TakeProfit float64 `json:"take_profit,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
|
// Common parameters
|
||||||
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
||||||
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
||||||
@@ -189,12 +198,23 @@ type OIDeltaData struct {
|
|||||||
|
|
||||||
// StrategyEngine strategy execution engine
|
// StrategyEngine strategy execution engine
|
||||||
type StrategyEngine struct {
|
type StrategyEngine struct {
|
||||||
config *store.StrategyConfig
|
config *store.StrategyConfig
|
||||||
|
nofxosClient *nofxos.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStrategyEngine creates strategy execution engine
|
// NewStrategyEngine creates strategy execution engine
|
||||||
func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine {
|
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
|
// GetRiskControlConfig gets risk control configuration
|
||||||
@@ -202,6 +222,19 @@ func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
|||||||
return e.config.RiskControl
|
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
|
// GetConfig gets complete strategy configuration
|
||||||
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
func (e *StrategyEngine) GetConfig() *store.StrategyConfig {
|
||||||
return e.config
|
return e.config
|
||||||
@@ -239,7 +272,7 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
|||||||
// Ensure OITopDataMap is initialized
|
// Ensure OITopDataMap is initialized
|
||||||
if ctx.OITopDataMap == nil {
|
if ctx.OITopDataMap == nil {
|
||||||
ctx.OITopDataMap = make(map[string]*OITopData)
|
ctx.OITopDataMap = make(map[string]*OITopData)
|
||||||
oiPositions, err := provider.GetOITopPositions()
|
oiPositions, err := engine.nofxosClient.GetOITopPositions()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, pos := range oiPositions {
|
for _, pos := range oiPositions {
|
||||||
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
ctx.OITopDataMap[pos.Symbol] = &OITopData{
|
||||||
@@ -385,13 +418,6 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
|
|
||||||
coinSource := e.config.CoinSource
|
coinSource := e.config.CoinSource
|
||||||
|
|
||||||
if coinSource.CoinPoolAPIURL != "" {
|
|
||||||
provider.SetCoinPoolAPI(coinSource.CoinPoolAPIURL)
|
|
||||||
}
|
|
||||||
if coinSource.OITopAPIURL != "" {
|
|
||||||
provider.SetOITopAPI(coinSource.OITopAPIURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch coinSource.SourceType {
|
switch coinSource.SourceType {
|
||||||
case "static":
|
case "static":
|
||||||
for _, symbol := range coinSource.StaticCoins {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
@@ -401,12 +427,13 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
Sources: []string{"static"},
|
Sources: []string{"static"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return candidates, nil
|
|
||||||
|
|
||||||
case "coinpool":
|
return e.filterExcludedCoins(candidates), nil
|
||||||
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
|
|
||||||
if !coinSource.UseCoinPool {
|
case "ai500":
|
||||||
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins")
|
// 检查 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 {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
candidates = append(candidates, CandidateCoin{
|
candidates = append(candidates, CandidateCoin{
|
||||||
@@ -414,9 +441,14 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
Sources: []string{"static"},
|
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":
|
case "oi_top":
|
||||||
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种
|
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种
|
||||||
@@ -429,15 +461,40 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
Sources: []string{"static"},
|
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":
|
case "mixed":
|
||||||
if coinSource.UseCoinPool {
|
if coinSource.UseAI500 {
|
||||||
poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to get AI500 coin pool: %v", err)
|
logger.Infof("⚠️ Failed to get AI500 coins: %v", err)
|
||||||
} else {
|
} else {
|
||||||
for _, coin := range poolCoins {
|
for _, coin := range poolCoins {
|
||||||
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500")
|
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 {
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
symbol = market.Normalize(symbol)
|
symbol = market.Normalize(symbol)
|
||||||
if _, exists := symbolSources[symbol]; !exists {
|
if _, exists := symbolSources[symbol]; !exists {
|
||||||
@@ -471,19 +539,45 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
Sources: sources,
|
Sources: sources,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return candidates, nil
|
return e.filterExcludedCoins(candidates), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown coin source type: %s", coinSource.SourceType)
|
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 {
|
if limit <= 0 {
|
||||||
limit = 30
|
limit = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
symbols, err := provider.GetTopRatedCoins(limit)
|
symbols, err := e.nofxosClient.GetTopRatedCoins(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -500,10 +594,10 @@ func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) {
|
|||||||
|
|
||||||
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
positions, err := provider.GetOITopPositions()
|
positions, err := e.nofxosClient.GetOITopPositions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -522,6 +616,30 @@ func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) {
|
|||||||
return candidates, nil
|
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
|
// External & Quant Data
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -610,50 +728,82 @@ func extractJSONPath(data interface{}, path string) interface{} {
|
|||||||
|
|
||||||
// FetchQuantData fetches quantitative data for a single coin
|
// FetchQuantData fetches quantitative data for a single coin
|
||||||
func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
apiURL := e.config.Indicators.QuantDataAPIURL
|
// Use nofxos client with unified API key
|
||||||
url := strings.Replace(apiURL, "{symbol}", symbol, -1)
|
include := "oi,price"
|
||||||
|
if e.config.Indicators.EnableQuantNetflow {
|
||||||
|
include = "netflow,oi,price"
|
||||||
|
}
|
||||||
|
|
||||||
// SSRF Protection: Validate URL before making request
|
nofxosData, err := e.nofxosClient.GetCoinData(symbol, include)
|
||||||
resp, err := security.SafeGet(url, 10*time.Second)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return nil, fmt.Errorf("failed to fetch quant data: %w", err)
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("HTTP status code: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
if nofxosData == nil {
|
||||||
if err != nil {
|
return nil, nil
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiResp struct {
|
// Convert nofxos.QuantData to kernel.QuantData
|
||||||
Code int `json:"code"`
|
quantData := &QuantData{
|
||||||
Data *QuantData `json:"data"`
|
Symbol: nofxosData.Symbol,
|
||||||
|
Price: nofxosData.Price,
|
||||||
|
PriceChange: nofxosData.PriceChange,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
// Convert OI data
|
||||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
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 {
|
// Convert Netflow data
|
||||||
return nil, fmt.Errorf("API returned error code: %d", apiResp.Code)
|
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
|
// FetchQuantDataBatch batch fetches quantitative data
|
||||||
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*QuantData {
|
||||||
result := make(map[string]*QuantData)
|
result := make(map[string]*QuantData)
|
||||||
|
|
||||||
if !e.config.Indicators.EnableQuantData || e.config.Indicators.QuantDataAPIURL == "" {
|
if !e.config.Indicators.EnableQuantData {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,28 +822,12 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FetchOIRankingData fetches market-wide OI ranking data
|
// FetchOIRankingData fetches market-wide OI ranking data
|
||||||
func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||||
indicators := e.config.Indicators
|
indicators := e.config.Indicators
|
||||||
if !indicators.EnableOIRanking {
|
if !indicators.EnableOIRanking {
|
||||||
return nil
|
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
|
duration := indicators.OIRankingDuration
|
||||||
if duration == "" {
|
if duration == "" {
|
||||||
duration = "1h"
|
duration = "1h"
|
||||||
@@ -706,7 +840,7 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
|||||||
|
|
||||||
logger.Infof("📊 Fetching OI ranking data (duration: %s, limit: %d)", duration, limit)
|
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 {
|
if err != nil {
|
||||||
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
logger.Warnf("⚠️ Failed to fetch OI ranking data: %v", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -718,6 +852,68 @@ func (e *StrategyEngine) FetchOIRankingData() *provider.OIRankingData {
|
|||||||
return data
|
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
|
// Prompt Building - System Prompt
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -729,7 +925,7 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
|||||||
promptSections := e.config.PromptSections
|
promptSections := e.config.PromptSections
|
||||||
|
|
||||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||||
lang := detectLanguage(promptSections.RoleDefinition)
|
lang := e.GetLanguage()
|
||||||
schemaPrompt := GetSchemaPrompt(lang)
|
schemaPrompt := GetSchemaPrompt(lang)
|
||||||
sb.WriteString(schemaPrompt)
|
sb.WriteString(schemaPrompt)
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
@@ -920,7 +1116,7 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
|||||||
sb.WriteString("- Funding rate\n")
|
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")
|
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)
|
// Historical trading statistics (helps AI understand past performance)
|
||||||
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 {
|
||||||
// Detect language from strategy config
|
// Get language from strategy config
|
||||||
lang := detectLanguage(e.config.PromptSections.RoleDefinition)
|
lang := e.GetLanguage()
|
||||||
|
|
||||||
// Win/Loss ratio
|
// Win/Loss ratio
|
||||||
var winLossRatio float64
|
var winLossRatio float64
|
||||||
@@ -1081,9 +1277,25 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
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)
|
// OI Ranking data (market-wide open interest changes)
|
||||||
if ctx.OIRankingData != nil {
|
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")
|
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 {
|
func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||||
if len(sources) > 1 {
|
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 {
|
} else if len(sources) == 1 {
|
||||||
switch sources[0] {
|
switch sources[0] {
|
||||||
case "ai500":
|
case "ai500":
|
||||||
return " (AI500)"
|
return " (AI500)"
|
||||||
case "oi_top":
|
case "oi_top":
|
||||||
return " (OI_Top position growth)"
|
return " (OI_Top 持仓增加)"
|
||||||
|
case "oi_low":
|
||||||
|
return " (OI_Low 持仓减少)"
|
||||||
case "static":
|
case "static":
|
||||||
return " (Manual selection)"
|
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 {
|
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) error {
|
||||||
for i, decision := range decisions {
|
for i := range decisions {
|
||||||
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
if err := validateDecision(&decisions[i], accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil {
|
||||||
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
return fmt.Errorf("decision #%d validation failed: %w", i+1, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package decision
|
package kernel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
|
"nofx/provider/nofxos"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -89,11 +90,11 @@ func formatContextData(ctx *Context, lang Language) string {
|
|||||||
|
|
||||||
// 7. OI排名数据(如果有)
|
// 7. OI排名数据(如果有)
|
||||||
if ctx.OIRankingData != nil {
|
if ctx.OIRankingData != nil {
|
||||||
|
nofxosLang := nofxos.LangEnglish
|
||||||
if lang == LangChinese {
|
if lang == LangChinese {
|
||||||
sb.WriteString(formatOIRankingZH(ctx.OIRankingData))
|
nofxosLang = nofxos.LangChinese
|
||||||
} else {
|
|
||||||
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
|
|
||||||
}
|
}
|
||||||
|
sb.WriteString(nofxos.FormatOIRankingForAI(ctx.OIRankingData, nofxosLang))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -354,11 +355,6 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatOIRankingZH 格式化OI排名数据(中文)
|
|
||||||
func formatOIRankingZH(oiData interface{}) string {
|
|
||||||
// TODO: 根据实际OIRankingData结构实现
|
|
||||||
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOIInterpretationZH 获取OI变化解读(中文)
|
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||||
@@ -624,10 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatOIRankingEN 格式化OI排名数据(英文)
|
|
||||||
func formatOIRankingEN(oiData interface{}) string {
|
|
||||||
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOIInterpretationEN 获取OI变化解读(英文)
|
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package decision
|
package kernel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
package decision
|
package kernel
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Trading Data Schema - 交易数据字典
|
// Trading Data Schema - 交易数据字典
|
||||||
@@ -481,18 +479,6 @@ func getSchemaPromptZH() string {
|
|||||||
prompt += formatFieldDefZH(key, field)
|
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解读
|
// OI解读
|
||||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\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_PriceUp.ZH + "\n"
|
||||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.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
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
|
|||||||
prompt += formatFieldDefEN(key, field)
|
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
|
// OI Interpretation
|
||||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\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 Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
|
||||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.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
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package decision
|
package kernel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -147,10 +147,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
|||||||
"交易指标",
|
"交易指标",
|
||||||
"持仓指标",
|
"持仓指标",
|
||||||
"市场数据",
|
"市场数据",
|
||||||
"交易规则",
|
|
||||||
"风险管理",
|
|
||||||
"持仓量(OI)变化解读",
|
"持仓量(OI)变化解读",
|
||||||
"常见错误",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, keyword := range mustContain {
|
for _, keyword := range mustContain {
|
||||||
@@ -174,10 +171,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
|||||||
"Trade Metrics",
|
"Trade Metrics",
|
||||||
"Position Metrics",
|
"Position Metrics",
|
||||||
"Market Data",
|
"Market Data",
|
||||||
"Trading Rules",
|
|
||||||
"Risk Management",
|
|
||||||
"Open Interest",
|
"Open Interest",
|
||||||
"Common Mistakes",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, keyword := range mustContain {
|
for _, keyword := range mustContain {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package decision
|
package kernel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
2
main.go
2
main.go
@@ -78,7 +78,7 @@ func main() {
|
|||||||
logger.Fatalf("❌ Failed to initialize database: %v", err)
|
logger.Fatalf("❌ Failed to initialize database: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
backtest.UseDatabase(st.DB())
|
backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
|
||||||
|
|
||||||
// Initialize installation ID for experience improvement (anonymous statistics)
|
// Initialize installation ID for experience improvement (anonymous statistics)
|
||||||
initInstallationID(st)
|
initInstallationID(st)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"nofx/debate"
|
"nofx/debate"
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
@@ -19,7 +19,7 @@ type TraderExecutorAdapter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteDecision executes a trading decision
|
// 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)
|
return a.autoTrader.ExecuteDecision(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
|||||||
// Concurrently fetch data for each trader
|
// Concurrently fetch data for each trader
|
||||||
for i, t := range traders {
|
for i, t := range traders {
|
||||||
go func(index int, trader *trader.AutoTrader) {
|
go func(index int, trader *trader.AutoTrader) {
|
||||||
// Set timeout to 3 seconds for single trader
|
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Use channel for timeout control
|
// Use channel for timeout control
|
||||||
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
|||||||
}
|
}
|
||||||
case err := <-errorChan:
|
case err := <-errorChan:
|
||||||
// Failed to get account info
|
// 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{}{
|
traderData = map[string]interface{}{
|
||||||
"trader_id": trader.GetID(),
|
"trader_id": trader.GetID(),
|
||||||
"trader_name": trader.GetName(),
|
"trader_name": trader.GetName(),
|
||||||
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
|||||||
}
|
}
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Timeout
|
// 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{}{
|
traderData = map[string]interface{}{
|
||||||
"trader_id": trader.GetID(),
|
"trader_id": trader.GetID(),
|
||||||
"trader_name": trader.GetName(),
|
"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)
|
// RemoveTrader removes a trader from memory (does not affect database)
|
||||||
// Used to force reload when updating trader configuration
|
// Used to force reload when updating trader configuration
|
||||||
|
// If the trader is running, it will be stopped first
|
||||||
func (tm *TraderManager) RemoveTrader(traderID string) {
|
func (tm *TraderManager) RemoveTrader(traderID string) {
|
||||||
tm.mu.Lock()
|
tm.mu.Lock()
|
||||||
defer tm.mu.Unlock()
|
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)
|
delete(tm.traders, traderID)
|
||||||
logger.Infof("✓ Trader %s removed from memory", traderID)
|
logger.Infof("✓ Trader %s removed from memory", traderID)
|
||||||
}
|
}
|
||||||
@@ -606,7 +613,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
|||||||
continue
|
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)
|
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
|
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)
|
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{
|
traderConfig := trader.AutoTraderConfig{
|
||||||
ID: traderCfg.ID,
|
ID: traderCfg.ID,
|
||||||
Name: traderCfg.Name,
|
Name: traderCfg.Name,
|
||||||
@@ -664,6 +671,9 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
StrategyConfig: strategyConfig,
|
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)
|
// Set API keys based on exchange type (convert EncryptedString to string)
|
||||||
switch exchangeCfg.ExchangeType {
|
switch exchangeCfg.ExchangeType {
|
||||||
case "binance":
|
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) {
|
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||||
return calculateBOLL(klines, period, multiplier)
|
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")
|
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
|
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"
|
"nofx/store"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -83,7 +84,7 @@ func main() {
|
|||||||
filledOrders++
|
filledOrders++
|
||||||
|
|
||||||
// 检查 filled_at
|
// 检查 filled_at
|
||||||
if !order.FilledAt.IsZero() {
|
if order.FilledAt > 0 {
|
||||||
withFilledAt++
|
withFilledAt++
|
||||||
} else {
|
} else {
|
||||||
missingFilledAt++
|
missingFilledAt++
|
||||||
@@ -119,8 +120,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filledAtStr := "N/A"
|
filledAtStr := "N/A"
|
||||||
if !order.FilledAt.IsZero() {
|
if order.FilledAt > 0 {
|
||||||
filledAtStr = order.FilledAt.Format("01-02 15:04")
|
filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
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,
|
"enabled": enabled,
|
||||||
"custom_api_url": customAPIURL,
|
"custom_api_url": customAPIURL,
|
||||||
"custom_model_name": customModelName,
|
"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 is not empty, update it (encryption handled by crypto.EncryptedString)
|
||||||
if apiKey != "" {
|
if apiKey != "" {
|
||||||
@@ -167,7 +167,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
|||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
"custom_api_url": customAPIURL,
|
"custom_api_url": customAPIURL,
|
||||||
"custom_model_name": customModelName,
|
"custom_model_name": customModelName,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now().UTC(),
|
||||||
}
|
}
|
||||||
if apiKey != "" {
|
if apiKey != "" {
|
||||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func (BacktestCheckpoint) TableName() string {
|
|||||||
type BacktestEquity struct {
|
type BacktestEquity struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
|
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"`
|
Equity float64 `gorm:"column:equity;not null"`
|
||||||
Available float64 `gorm:"column:available;not null"`
|
Available float64 `gorm:"column:available;not null"`
|
||||||
PnL float64 `gorm:"column:pnl;not null"`
|
PnL float64 `gorm:"column:pnl;not null"`
|
||||||
@@ -164,7 +164,7 @@ func (BacktestEquity) TableName() string {
|
|||||||
type BacktestTrade struct {
|
type BacktestTrade struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
|
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"`
|
Symbol string `gorm:"column:symbol;not null"`
|
||||||
Action string `gorm:"column:action;not null"`
|
Action string `gorm:"column:action;not null"`
|
||||||
Side string `gorm:"column:side;default:''"`
|
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)
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
|
||||||
|
|
||||||
if tableExists > 0 {
|
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_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_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)`)
|
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,
|
"aster_signer": asterSigner,
|
||||||
"lighter_wallet_addr": lighterWalletAddr,
|
"lighter_wallet_addr": lighterWalletAddr,
|
||||||
"lighter_api_key_index": lighterApiKeyIndex,
|
"lighter_api_key_index": lighterApiKeyIndex,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update encrypted fields if not empty
|
// 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).
|
Where("id = ? AND user_id = ?", id, userID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"account_name": accountName,
|
"account_name": accountName,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now().UTC(),
|
||||||
})
|
})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -21,6 +22,10 @@ func DB() *gorm.DB {
|
|||||||
func InitGorm(dbPath string) (*gorm.DB, error) {
|
func InitGorm(dbPath string) (*gorm.DB, error) {
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
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{
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Silent),
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TraderOrder order record
|
// TraderOrder order record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderOrder struct {
|
type TraderOrder struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_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"`
|
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
|
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"`
|
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"`
|
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||||
Type string `gorm:"column:type;not null" json:"type"`
|
Type string `gorm:"column:type;not null" json:"type"`
|
||||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_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"`
|
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"`
|
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||||
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"`
|
FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for TraderOrder
|
// TableName returns the table name for TraderOrder
|
||||||
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TraderFill trade record
|
// TraderFill trade record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderFill struct {
|
type TraderFill struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_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"`
|
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||||
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
|
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"`
|
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"`
|
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"`
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for TraderFill
|
// 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))
|
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
|
// 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_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)`)
|
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,
|
"filled_quantity": filledQty,
|
||||||
"avg_fill_price": avgPrice,
|
"avg_fill_price": avgPrice,
|
||||||
"commission": commission,
|
"commission": commission,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "FILLED" {
|
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
|
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
|
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
|
// GetOrderFills gets order's fill records
|
||||||
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
|
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
|
||||||
var fills []*TraderFill
|
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
|
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
|
||||||
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
|
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
|
||||||
type symbolMaxID struct {
|
type symbolTradeID struct {
|
||||||
Symbol string
|
Symbol string
|
||||||
MaxTradeID int64
|
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{}).
|
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).
|
Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID).
|
||||||
Group("symbol").
|
|
||||||
Find(&results).Error
|
Find(&results).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If CAST fails (non-numeric trade IDs), fallback to string comparison
|
return nil, fmt.Errorf("failed to query trade IDs: %w", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find max trade ID per symbol in Go (handles 64-bit integers properly)
|
||||||
result := make(map[string]int64)
|
result := make(map[string]int64)
|
||||||
for _, r := range results {
|
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
|
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
|
// TraderPosition position record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderPosition struct {
|
type TraderPosition struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_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"`
|
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"`
|
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"`
|
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
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"`
|
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"`
|
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
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"`
|
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"`
|
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||||
Source string `gorm:"column:source;default:system" json:"source"`
|
Source string `gorm:"column:source;default:system" json:"source"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name
|
// TableName returns the table name
|
||||||
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
|
|||||||
var tableExists int64
|
var tableExists int64
|
||||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
||||||
if tableExists > 0 {
|
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
|
// 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 != ''`)
|
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
|
return nil
|
||||||
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
|||||||
|
|
||||||
// ClosePosition closes position
|
// ClosePosition closes position
|
||||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": now,
|
"exit_time": nowMs,
|
||||||
"realized_pnl": realizedPnL,
|
"realized_pnl": realizedPnL,
|
||||||
"fee": fee,
|
"fee": fee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": nowMs,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +158,19 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
|||||||
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
||||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||||
newFee := pos.Fee + addFee
|
newFee := pos.Fee + addFee
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
|
|
||||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"quantity": newQty,
|
"quantity": newQty,
|
||||||
"entry_quantity": newEntryQty,
|
"entry_quantity": newEntryQty,
|
||||||
"entry_price": newEntryPrice,
|
"entry_price": newEntryPrice,
|
||||||
"fee": newFee,
|
"fee": newFee,
|
||||||
|
"updated_at": nowMs,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReducePositionQuantity reduces position quantity for partial close
|
// 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 {
|
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||||
var pos TraderPosition
|
var pos TraderPosition
|
||||||
if err := s.db.First(&pos, id).Error; err != nil {
|
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
|
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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"quantity": newQty,
|
"quantity": newQty,
|
||||||
"fee": newFee,
|
"fee": newFee,
|
||||||
"exit_price": newExitPrice,
|
"exit_price": newExitPrice,
|
||||||
"realized_pnl": newPnL,
|
"realized_pnl": newPnL,
|
||||||
|
"updated_at": nowMs,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
||||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exchange_id": exchangeID,
|
"exchange_id": exchangeID,
|
||||||
"exchange_type": exchangeType,
|
"exchange_type": exchangeType,
|
||||||
|
"updated_at": nowMs,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClosePositionFully marks position as fully closed
|
// 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
|
var pos TraderPosition
|
||||||
if err := s.db.First(&pos, id).Error; err != nil {
|
if err := s.db.First(&pos, id).Error; err != nil {
|
||||||
return fmt.Errorf("failed to get position: %w", err)
|
return fmt.Errorf("failed to get position: %w", err)
|
||||||
@@ -205,11 +244,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
|
|||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": exitTime,
|
"exit_time": exitTimeMs,
|
||||||
"realized_pnl": totalRealizedPnL,
|
"realized_pnl": totalRealizedPnL,
|
||||||
"fee": totalFee,
|
"fee": totalFee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,13 +472,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
|||||||
EntryPrice: pos.EntryPrice,
|
EntryPrice: pos.EntryPrice,
|
||||||
ExitPrice: pos.ExitPrice,
|
ExitPrice: pos.ExitPrice,
|
||||||
RealizedPnL: pos.RealizedPnL,
|
RealizedPnL: pos.RealizedPnL,
|
||||||
EntryTime: pos.EntryTime.Unix(),
|
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
t.ExitTime = pos.ExitTime.Unix()
|
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
|
||||||
duration := pos.ExitTime.Sub(pos.EntryTime)
|
durationMs := pos.ExitTime - pos.EntryTime
|
||||||
t.HoldDuration = formatDuration(duration)
|
t.HoldDuration = formatDurationMs(durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.EntryPrice > 0 {
|
if pos.EntryPrice > 0 {
|
||||||
@@ -457,26 +497,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
|||||||
|
|
||||||
// formatDuration formats a duration
|
// formatDuration formats a duration
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
if d < time.Minute {
|
return formatDurationMs(d.Milliseconds())
|
||||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if minutes < 60 {
|
||||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
return fmt.Sprintf("%dm", minutes)
|
||||||
}
|
}
|
||||||
if d < 24*time.Hour {
|
if hours < 24 {
|
||||||
hours := int(d.Hours())
|
remainingMins := minutes % 60
|
||||||
minutes := int(d.Minutes()) % 60
|
if remainingMins == 0 {
|
||||||
if minutes == 0 {
|
|
||||||
return fmt.Sprintf("%dh", hours)
|
return fmt.Sprintf("%dh", hours)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
return fmt.Sprintf("%dh%dm", hours, remainingMins)
|
||||||
}
|
}
|
||||||
days := int(d.Hours()) / 24
|
remainingHours := hours % 24
|
||||||
hours := int(d.Hours()) % 24
|
if remainingHours == 0 {
|
||||||
if hours == 0 {
|
|
||||||
return fmt.Sprintf("%dd", days)
|
return fmt.Sprintf("%dd", days)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%dd%dh", days, hours)
|
return fmt.Sprintf("%dd%dh", days, remainingHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||||
@@ -566,8 +614,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
|
|||||||
s.WinTrades++
|
s.WinTrades++
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||||
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,7 +663,7 @@ type HoldingTimeStats struct {
|
|||||||
// GetHoldingTimeStats analyzes performance by holding duration
|
// GetHoldingTimeStats analyzes performance by holding duration
|
||||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||||
var positions []TraderPosition
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
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 {
|
for _, pos := range positions {
|
||||||
if pos.ExitTime == nil {
|
if pos.ExitTime == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours()
|
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
|
||||||
|
|
||||||
var rangeKey string
|
var rangeKey string
|
||||||
switch {
|
switch {
|
||||||
@@ -792,12 +840,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
|
|||||||
|
|
||||||
// Calculate average holding time
|
// Calculate average holding time
|
||||||
var positions []TraderPosition
|
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 {
|
if len(positions) > 0 {
|
||||||
var totalMins float64
|
var totalMins float64
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
||||||
@@ -917,6 +965,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClosedPnLRecord represents a closed position record from exchange
|
// ClosedPnLRecord represents a closed position record from exchange
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC)
|
||||||
type ClosedPnLRecord struct {
|
type ClosedPnLRecord struct {
|
||||||
Symbol string
|
Symbol string
|
||||||
Side string
|
Side string
|
||||||
@@ -926,8 +975,8 @@ type ClosedPnLRecord struct {
|
|||||||
RealizedPnL float64
|
RealizedPnL float64
|
||||||
Fee float64
|
Fee float64
|
||||||
Leverage int
|
Leverage int
|
||||||
EntryTime time.Time
|
EntryTime int64 // Unix milliseconds UTC
|
||||||
ExitTime time.Time
|
ExitTime int64 // Unix milliseconds UTC
|
||||||
OrderID string
|
OrderID string
|
||||||
CloseType string
|
CloseType string
|
||||||
ExchangeID string
|
ExchangeID string
|
||||||
@@ -954,7 +1003,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
|
|
||||||
exchangePositionID := record.ExchangeID
|
exchangePositionID := record.ExchangeID
|
||||||
if exchangePositionID == "" {
|
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)
|
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
|
||||||
@@ -965,19 +1014,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
exitTime := record.ExitTime
|
exitTimeMs := record.ExitTime
|
||||||
entryTime := record.EntryTime
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
if entryTime.IsZero() || entryTime.Year() < 2000 {
|
if entryTimeMs < minValidTime {
|
||||||
entryTime = exitTime
|
entryTimeMs = exitTimeMs
|
||||||
}
|
}
|
||||||
if entryTime.After(exitTime) {
|
if entryTimeMs > exitTimeMs {
|
||||||
entryTime = exitTime
|
entryTimeMs = exitTimeMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
pos := &TraderPosition{
|
pos := &TraderPosition{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -988,16 +1040,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
Quantity: record.Quantity,
|
Quantity: record.Quantity,
|
||||||
EntryQuantity: record.Quantity,
|
EntryQuantity: record.Quantity,
|
||||||
EntryPrice: record.EntryPrice,
|
EntryPrice: record.EntryPrice,
|
||||||
EntryTime: entryTime,
|
EntryTime: entryTimeMs,
|
||||||
ExitPrice: record.ExitPrice,
|
ExitPrice: record.ExitPrice,
|
||||||
ExitOrderID: record.OrderID,
|
ExitOrderID: record.OrderID,
|
||||||
ExitTime: &exitTime,
|
ExitTime: exitTimeMs,
|
||||||
RealizedPnL: record.RealizedPnL,
|
RealizedPnL: record.RealizedPnL,
|
||||||
Fee: record.Fee,
|
Fee: record.Fee,
|
||||||
Leverage: record.Leverage,
|
Leverage: record.Leverage,
|
||||||
Status: "CLOSED",
|
Status: "CLOSED",
|
||||||
CloseReason: record.CloseType,
|
CloseReason: record.CloseType,
|
||||||
Source: "sync",
|
Source: "sync",
|
||||||
|
CreatedAt: nowMs,
|
||||||
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.db.Create(pos).Error
|
err = s.db.Create(pos).Error
|
||||||
@@ -1011,21 +1065,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastClosedPositionTime gets the most recent exit time
|
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
|
||||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
|
||||||
var pos TraderPosition
|
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").
|
Order("exit_time DESC").
|
||||||
First(&pos).Error
|
First(&pos).Error
|
||||||
|
|
||||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil {
|
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
|
||||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
|
||||||
}
|
}
|
||||||
if err != 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
|
// 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
|
// 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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": exitTime,
|
"exit_time": exitTimeMs,
|
||||||
"realized_pnl": realizedPnL,
|
"realized_pnl": realizedPnL,
|
||||||
"fee": fee,
|
"fee": fee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProcessTrade processes a single trade and updates position accordingly
|
// ProcessTrade processes a single trade and updates position accordingly
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) ProcessTrade(
|
func (pb *PositionBuilder) ProcessTrade(
|
||||||
traderID, exchangeID, exchangeType, symbol, side, action string,
|
traderID, exchangeID, exchangeType, symbol, side, action string,
|
||||||
quantity, price, fee, realizedPnL float64,
|
quantity, price, fee, realizedPnL float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
if strings.HasPrefix(action, "open_") {
|
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_") {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleOpen handles opening positions (create new or average into existing)
|
// handleOpen handles opening positions (create new or average into existing)
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) handleOpen(
|
func (pb *PositionBuilder) handleOpen(
|
||||||
traderID, exchangeID, exchangeType, symbol, side string,
|
traderID, exchangeID, exchangeType, symbol, side string,
|
||||||
quantity, price, fee float64,
|
quantity, price, fee float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
// Get existing OPEN position for (symbol, side)
|
// 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)
|
return fmt.Errorf("failed to get open position: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
// Create new position
|
// Create new position
|
||||||
position := &TraderPosition{
|
position := &TraderPosition{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
ExchangeType: exchangeType,
|
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,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
EntryPrice: price,
|
EntryPrice: price,
|
||||||
EntryOrderID: orderID,
|
EntryOrderID: orderID,
|
||||||
EntryTime: tradeTime,
|
EntryTime: tradeTimeMs,
|
||||||
Leverage: 1,
|
Leverage: 1,
|
||||||
Status: "OPEN",
|
Status: "OPEN",
|
||||||
Source: "sync",
|
Source: "sync",
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: nowMs,
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
return pb.positionStore.CreateOpenPosition(position)
|
return pb.positionStore.CreateOpenPosition(position)
|
||||||
}
|
}
|
||||||
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleClose handles closing positions (partial or full)
|
// handleClose handles closing positions (partial or full)
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) handleClose(
|
func (pb *PositionBuilder) handleClose(
|
||||||
traderID, exchangeID, exchangeType, symbol, side string,
|
traderID, exchangeID, exchangeType, symbol, side string,
|
||||||
quantity, price, fee, realizedPnL float64,
|
quantity, price, fee, realizedPnL float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
// Get OPEN position
|
// Get OPEN position
|
||||||
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
|
|||||||
position.ID,
|
position.ID,
|
||||||
finalExitPrice,
|
finalExitPrice,
|
||||||
orderID,
|
orderID,
|
||||||
tradeTime,
|
tradeTimeMs,
|
||||||
totalPnL,
|
totalPnL,
|
||||||
totalFee,
|
totalFee,
|
||||||
"sync",
|
"sync",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Store struct {
|
|||||||
strategy *StrategyStore
|
strategy *StrategyStore
|
||||||
equity *EquityStore
|
equity *EquityStore
|
||||||
order *OrderStore
|
order *OrderStore
|
||||||
|
grid *GridStore
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,9 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Order().InitTables(); err != nil {
|
if err := s.Order().InitTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize order tables: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +283,16 @@ func (s *Store) Order() *OrderStore {
|
|||||||
return s.order
|
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
|
// Close closes database connection
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
if s.driver != nil {
|
if s.driver != nil {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ func (Strategy) TableName() string { return "strategies" }
|
|||||||
|
|
||||||
// StrategyConfig strategy configuration details (JSON structure)
|
// StrategyConfig strategy configuration details (JSON structure)
|
||||||
type StrategyConfig struct {
|
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
|
// coin source configuration
|
||||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||||
// quantitative data configuration
|
// quantitative data configuration
|
||||||
@@ -42,6 +48,39 @@ type StrategyConfig struct {
|
|||||||
RiskControl RiskControlConfig `json:"risk_control"`
|
RiskControl RiskControlConfig `json:"risk_control"`
|
||||||
// editable sections of System Prompt
|
// editable sections of System Prompt
|
||||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
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
|
// PromptSectionsConfig editable sections of System Prompt
|
||||||
@@ -58,22 +97,25 @@ type PromptSectionsConfig struct {
|
|||||||
|
|
||||||
// CoinSourceConfig coin source configuration
|
// CoinSourceConfig coin source configuration
|
||||||
type CoinSourceConfig struct {
|
type CoinSourceConfig struct {
|
||||||
// source type: "static" | "coinpool" | "oi_top" | "mixed"
|
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
// static coin list (used when source_type = "static")
|
// static coin list (used when source_type = "static")
|
||||||
StaticCoins []string `json:"static_coins,omitempty"`
|
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
|
// whether to use AI500 coin pool
|
||||||
UseCoinPool bool `json:"use_coin_pool"`
|
UseAI500 bool `json:"use_ai500"`
|
||||||
// AI500 coin pool maximum count
|
// AI500 coin pool maximum count
|
||||||
CoinPoolLimit int `json:"coin_pool_limit,omitempty"`
|
AI500Limit int `json:"ai500_limit,omitempty"`
|
||||||
// AI500 coin pool API URL (strategy-level configuration)
|
// whether to use OI Top (持仓增加榜,适合做多)
|
||||||
CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"`
|
|
||||||
// whether to use OI Top
|
|
||||||
UseOITop bool `json:"use_oi_top"`
|
UseOITop bool `json:"use_oi_top"`
|
||||||
// OI Top maximum count
|
// OI Top maximum count
|
||||||
OITopLimit int `json:"oi_top_limit,omitempty"`
|
OITopLimit int `json:"oi_top_limit,omitempty"`
|
||||||
// OI Top API URL (strategy-level configuration)
|
// whether to use OI Low (持仓减少榜,适合做空)
|
||||||
OITopAPIURL string `json:"oi_top_api_url,omitempty"`
|
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
|
// IndicatorConfig indicator configuration
|
||||||
@@ -101,16 +143,30 @@ type IndicatorConfig struct {
|
|||||||
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
BOLLPeriods []int `json:"boll_periods,omitempty"` // default [20] - can select multiple timeframes
|
||||||
// external data sources
|
// external data sources
|
||||||
ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"`
|
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)
|
// quantitative data sources (capital flow, position changes, price changes)
|
||||||
EnableQuantData bool `json:"enable_quant_data"` // whether to enable quantitative data
|
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
|
||||||
EnableQuantOI bool `json:"enable_quant_oi"` // whether to show OI data
|
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
||||||
EnableQuantNetflow bool `json:"enable_quant_netflow"` // whether to show Netflow data
|
|
||||||
// OI ranking data (market-wide open interest increase/decrease rankings)
|
// OI ranking data (market-wide open interest increase/decrease rankings)
|
||||||
EnableOIRanking bool `json:"enable_oi_ranking"` // whether to enable OI ranking data
|
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
|
OIRankingDuration string `json:"oi_ranking_duration,omitempty"` // duration: 1h, 4h, 24h
|
||||||
OIRankingLimit int `json:"oi_ranking_limit,omitempty"` // number of entries (default 10)
|
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
|
// KlineConfig K-line configuration
|
||||||
@@ -172,14 +228,7 @@ func NewStrategyStore(db *gorm.DB) *StrategyStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *StrategyStore) initTables() error {
|
func (s *StrategyStore) initTables() error {
|
||||||
// For PostgreSQL with existing table, skip AutoMigrate
|
// AutoMigrate will add missing columns without dropping existing data
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.db.AutoMigrate(&Strategy{})
|
return s.db.AutoMigrate(&Strategy{})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,15 +239,22 @@ func (s *StrategyStore) initDefaultData() error {
|
|||||||
|
|
||||||
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
// GetDefaultStrategyConfig returns the default strategy configuration for the given language
|
||||||
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||||
|
// Normalize language to "zh" or "en"
|
||||||
|
normalizedLang := "en"
|
||||||
|
if lang == "zh" {
|
||||||
|
normalizedLang = "zh"
|
||||||
|
}
|
||||||
|
|
||||||
config := StrategyConfig{
|
config := StrategyConfig{
|
||||||
|
Language: normalizedLang,
|
||||||
CoinSource: CoinSourceConfig{
|
CoinSource: CoinSourceConfig{
|
||||||
SourceType: "coinpool",
|
SourceType: "ai500",
|
||||||
UseCoinPool: true,
|
UseAI500: true,
|
||||||
CoinPoolLimit: 10,
|
AI500Limit: 10,
|
||||||
CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c",
|
UseOITop: false,
|
||||||
UseOITop: false,
|
OITopLimit: 10,
|
||||||
OITopLimit: 20,
|
UseOILow: false,
|
||||||
OITopAPIURL: "http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c",
|
OILowLimit: 10,
|
||||||
},
|
},
|
||||||
Indicators: IndicatorConfig{
|
Indicators: IndicatorConfig{
|
||||||
Klines: KlineConfig{
|
Klines: KlineConfig{
|
||||||
@@ -222,15 +278,24 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
|||||||
RSIPeriods: []int{7, 14},
|
RSIPeriods: []int{7, 14},
|
||||||
ATRPeriods: []int{14},
|
ATRPeriods: []int{14},
|
||||||
BOLLPeriods: []int{20},
|
BOLLPeriods: []int{20},
|
||||||
|
// NofxOS unified API key
|
||||||
|
NofxOSAPIKey: "cm_568c67eae410d912c54c",
|
||||||
|
// Quant data
|
||||||
EnableQuantData: true,
|
EnableQuantData: true,
|
||||||
QuantDataAPIURL: "http://nofxaios.com:30006/api/coin/{symbol}?include=netflow,oi,price&auth=cm_568c67eae410d912c54c",
|
|
||||||
EnableQuantOI: true,
|
EnableQuantOI: true,
|
||||||
EnableQuantNetflow: true,
|
EnableQuantNetflow: true,
|
||||||
// OI ranking data - market-wide OI increase/decrease rankings
|
// OI ranking data
|
||||||
EnableOIRanking: true,
|
EnableOIRanking: true,
|
||||||
OIRankingAPIURL: "http://nofxaios.com:30006",
|
|
||||||
OIRankingDuration: "1h",
|
OIRankingDuration: "1h",
|
||||||
OIRankingLimit: 10,
|
OIRankingLimit: 10,
|
||||||
|
// NetFlow ranking data
|
||||||
|
EnableNetFlowRanking: true,
|
||||||
|
NetFlowRankingDuration: "1h",
|
||||||
|
NetFlowRankingLimit: 10,
|
||||||
|
// Price ranking data
|
||||||
|
EnablePriceRanking: true,
|
||||||
|
PriceRankingDuration: "1h,4h,24h",
|
||||||
|
PriceRankingLimit: 10,
|
||||||
},
|
},
|
||||||
RiskControl: RiskControlConfig{
|
RiskControl: RiskControlConfig{
|
||||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||||
@@ -305,7 +370,7 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
|
|||||||
"config": strategy.Config,
|
"config": strategy.Config,
|
||||||
"is_public": strategy.IsPublic,
|
"is_public": strategy.IsPublic,
|
||||||
"config_visible": strategy.ConfigVisible,
|
"config_visible": strategy.ConfigVisible,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now().UTC(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type Trader struct {
|
|||||||
BTCETHLeverage int `gorm:"column:btc_eth_leverage;default:5" json:"btc_eth_leverage,omitempty"`
|
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"`
|
AltcoinLeverage int `gorm:"column:altcoin_leverage;default:5" json:"altcoin_leverage,omitempty"`
|
||||||
TradingSymbols string `gorm:"column:trading_symbols;default:''" json:"trading_symbols,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"`
|
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"`
|
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"`
|
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 {
|
if trader.ScanIntervalMinutes > 0 {
|
||||||
updates["scan_interval_minutes"] = trader.ScanIntervalMinutes
|
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{}).
|
return s.db.Model(&Trader{}).
|
||||||
@@ -245,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
|||||||
}
|
}
|
||||||
return traders, nil
|
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 {
|
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||||
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||||
"password_hash": passwordHash,
|
"password_hash": passwordHash,
|
||||||
"updated_at": time.Now(),
|
"updated_at": time.Now().UTC(),
|
||||||
}).Error
|
}).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 trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
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)
|
// 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
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record
|
// Create order record - use Unix milliseconds UTC
|
||||||
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -85,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: trade.Time,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: trade.Time,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -96,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -113,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -125,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1407,10 +1407,201 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
|||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
Time: time.UnixMilli(at.Time),
|
Time: time.UnixMilli(at.Time).UTC(),
|
||||||
}
|
}
|
||||||
result = append(result, trade)
|
result = append(result, trade)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"nofx/decision"
|
"nofx/kernel"
|
||||||
"nofx/experience"
|
"nofx/experience"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/market"
|
"nofx/market"
|
||||||
@@ -104,7 +104,7 @@ type AutoTrader struct {
|
|||||||
trader Trader // Use Trader interface (supports multiple platforms)
|
trader Trader // Use Trader interface (supports multiple platforms)
|
||||||
mcpClient mcp.AIClient
|
mcpClient mcp.AIClient
|
||||||
store *store.Store // Data storage (decision records, etc.)
|
store *store.Store // Data storage (decision records, etc.)
|
||||||
strategyEngine *decision.StrategyEngine // Strategy engine (uses strategy configuration)
|
strategyEngine *kernel.StrategyEngine // Strategy engine (uses strategy configuration)
|
||||||
cycleNumber int // Current cycle number
|
cycleNumber int // Current cycle number
|
||||||
initialBalance float64
|
initialBalance float64
|
||||||
dailyPnL float64
|
dailyPnL float64
|
||||||
@@ -123,6 +123,7 @@ type AutoTrader struct {
|
|||||||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||||||
lastBalanceSyncTime time.Time // Last balance sync time
|
lastBalanceSyncTime time.Time // Last balance sync time
|
||||||
userID string // User ID
|
userID string // User ID
|
||||||
|
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAutoTrader creates an automatic trader
|
// NewAutoTrader creates an automatic trader
|
||||||
@@ -310,7 +311,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
if config.StrategyConfig == nil {
|
if config.StrategyConfig == nil {
|
||||||
return nil, fmt.Errorf("[%s] strategy not configured", config.Name)
|
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)
|
logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name)
|
||||||
|
|
||||||
return &AutoTrader{
|
return &AutoTrader{
|
||||||
@@ -419,9 +420,25 @@ func (at *AutoTrader) Run() error {
|
|||||||
ticker := time.NewTicker(at.config.ScanInterval)
|
ticker := time.NewTicker(at.config.ScanInterval)
|
||||||
defer ticker.Stop()
|
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
|
// Execute immediately on first run
|
||||||
if err := at.runCycle(); err != nil {
|
if isGridStrategy {
|
||||||
logger.Infof("❌ Execution failed: %v", err)
|
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 {
|
for {
|
||||||
@@ -435,8 +452,14 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := at.runCycle(); err != nil {
|
if isGridStrategy {
|
||||||
logger.Infof("❌ Execution failed: %v", err)
|
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:
|
case <-at.stopMonitorCh:
|
||||||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
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)
|
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)
|
// Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve)
|
||||||
at.saveEquitySnapshot(ctx)
|
at.saveEquitySnapshot(ctx)
|
||||||
|
|
||||||
@@ -524,7 +553,7 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
|
|
||||||
// 5. Use strategy engine to call AI for decision
|
// 5. Use strategy engine to call AI for decision
|
||||||
logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]")
|
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 {
|
if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 {
|
||||||
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
record.AIRequestDurationMs = aiDecision.AIRequestDurationMs
|
||||||
@@ -585,8 +614,8 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
// logger.Infof(strings.Repeat("-", 70) + "\n")
|
||||||
|
|
||||||
// 7. Print AI decisions
|
// 7. Print AI decisions
|
||||||
// logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions))
|
// logger.Infof("📋 AI decision list (%d items):\n", len(kernel.Decisions))
|
||||||
// for i, d := range decision.Decisions {
|
// for i, d := range kernel.Decisions {
|
||||||
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
// logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning)
|
||||||
// if d.Action == "open_long" || d.Action == "open_short" {
|
// if d.Action == "open_long" || d.Action == "open_short" {
|
||||||
// logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f",
|
// 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,
|
TakeProfit: d.TakeProfit,
|
||||||
Confidence: d.Confidence,
|
Confidence: d.Confidence,
|
||||||
Reasoning: d.Reasoning,
|
Reasoning: d.Reasoning,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now().UTC(),
|
||||||
Success: false,
|
Success: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,7 +693,7 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildTradingContext builds trading context
|
// buildTradingContext builds trading context
|
||||||
func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||||
// 1. Get account information
|
// 1. Get account information
|
||||||
balance, err := at.trader.GetBalance()
|
balance, err := at.trader.GetBalance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -701,7 +730,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
return nil, fmt.Errorf("failed to get positions: %w", err)
|
return nil, fmt.Errorf("failed to get positions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var positionInfos []decision.PositionInfo
|
var positionInfos []kernel.PositionInfo
|
||||||
totalMarginUsed := 0.0
|
totalMarginUsed := 0.0
|
||||||
|
|
||||||
// Current position key set (for cleaning up closed position records)
|
// 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
|
// Priority 1: Get from database (trader_positions table) - most accurate
|
||||||
if at.store != nil {
|
if at.store != nil {
|
||||||
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||||||
if !dbPos.EntryTime.IsZero() {
|
if dbPos.EntryTime > 0 {
|
||||||
updateTime = dbPos.EntryTime.UnixMilli()
|
updateTime = dbPos.EntryTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -768,7 +797,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
peakPnlPct := at.peakPnLCache[posKey]
|
peakPnlPct := at.peakPnLCache[posKey]
|
||||||
at.peakPnLCacheMutex.RUnlock()
|
at.peakPnLCacheMutex.RUnlock()
|
||||||
|
|
||||||
positionInfos = append(positionInfos, decision.PositionInfo{
|
positionInfos = append(positionInfos, kernel.PositionInfo{
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
EntryPrice: entryPrice,
|
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)
|
logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage)
|
||||||
|
|
||||||
// 6. Build context
|
// 6. Build context
|
||||||
ctx := &decision.Context{
|
ctx := &kernel.Context{
|
||||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||||
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
RuntimeMinutes: int(time.Since(at.startTime).Minutes()),
|
||||||
CallCount: at.callCount,
|
CallCount: at.callCount,
|
||||||
BTCETHLeverage: btcEthLeverage,
|
BTCETHLeverage: btcEthLeverage,
|
||||||
AltcoinLeverage: altcoinLeverage,
|
AltcoinLeverage: altcoinLeverage,
|
||||||
Account: decision.AccountInfo{
|
Account: kernel.AccountInfo{
|
||||||
TotalEquity: totalEquity,
|
TotalEquity: totalEquity,
|
||||||
AvailableBalance: availableBalance,
|
AvailableBalance: availableBalance,
|
||||||
UnrealizedPnL: totalUnrealizedProfit,
|
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")
|
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,
|
Symbol: trade.Symbol,
|
||||||
Side: trade.Side,
|
Side: trade.Side,
|
||||||
EntryPrice: trade.EntryPrice,
|
EntryPrice: trade.EntryPrice,
|
||||||
@@ -881,7 +910,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
} else if stats.TotalTrades == 0 {
|
} else if stats.TotalTrades == 0 {
|
||||||
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
logger.Infof("⚠️ [%s] GetFullStats returned 0 trades (traderID=%s)", at.name, at.id)
|
||||||
} else {
|
} else {
|
||||||
ctx.TradingStats = &decision.TradingStats{
|
ctx.TradingStats = &kernel.TradingStats{
|
||||||
TotalTrades: stats.TotalTrades,
|
TotalTrades: stats.TotalTrades,
|
||||||
WinRate: stats.WinRate,
|
WinRate: stats.WinRate,
|
||||||
ProfitFactor: stats.ProfitFactor,
|
ProfitFactor: stats.ProfitFactor,
|
||||||
@@ -899,7 +928,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Get quantitative data (if enabled in strategy config)
|
// 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)
|
// Collect symbols to query (candidate coins + position coins)
|
||||||
symbolsToQuery := make(map[string]bool)
|
symbolsToQuery := make(map[string]bool)
|
||||||
for _, coin := range candidateCoins {
|
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
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeDecisionWithRecord executes AI decision and records detailed information
|
// 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 {
|
switch decision.Action {
|
||||||
case "open_long":
|
case "open_long":
|
||||||
return at.executeOpenLongWithRecord(decision, actionRecord)
|
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)
|
// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus)
|
||||||
// This is a public method that can be called by other modules
|
// 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)
|
logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol)
|
||||||
|
|
||||||
// Create a minimal action record for tracking
|
// 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
|
// 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)
|
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
||||||
|
|
||||||
// ⚠️ Get current positions for multiple checks
|
// ⚠️ 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
|
// 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)
|
logger.Infof(" 📉 Open short: %s", decision.Symbol)
|
||||||
|
|
||||||
// ⚠️ Get current positions for multiple checks
|
// ⚠️ 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
|
// 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)
|
logger.Infof(" 🔄 Close long: %s", decision.Symbol)
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
@@ -1277,7 +1326,7 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
// executeCloseShortWithRecord executes close short position and records detailed information
|
// 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)
|
logger.Infof(" 🔄 Close short: %s", decision.Symbol)
|
||||||
|
|
||||||
// Get current price
|
// Get current price
|
||||||
@@ -1345,6 +1394,12 @@ func (at *AutoTrader) GetID() string {
|
|||||||
return at.id
|
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
|
// GetName gets trader name
|
||||||
func (at *AutoTrader) GetName() string {
|
func (at *AutoTrader) GetName() string {
|
||||||
return at.name
|
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)
|
// 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 {
|
if at.store == nil || ctx == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1451,7 +1506,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
|||||||
isRunning := at.isRunning
|
isRunning := at.isRunning
|
||||||
at.isRunningMutex.RUnlock()
|
at.isRunningMutex.RUnlock()
|
||||||
|
|
||||||
return map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"trader_id": at.id,
|
"trader_id": at.id,
|
||||||
"trader_name": at.name,
|
"trader_name": at.name,
|
||||||
"ai_model": at.aiModel,
|
"ai_model": at.aiModel,
|
||||||
@@ -1466,6 +1521,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
|||||||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||||||
"ai_provider": aiProvider,
|
"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)
|
// 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
|
// sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait
|
||||||
// This avoids position stacking overflow when changing positions
|
// 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 {
|
if len(decisions) <= 1 {
|
||||||
return decisions
|
return decisions
|
||||||
}
|
}
|
||||||
@@ -1644,7 +1709,7 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy decision list
|
// Copy decision list
|
||||||
sorted := make([]decision.Decision, len(decisions))
|
sorted := make([]kernel.Decision, len(decisions))
|
||||||
copy(sorted, decisions)
|
copy(sorted, decisions)
|
||||||
|
|
||||||
// Sort by priority
|
// Sort by priority
|
||||||
@@ -1947,6 +2012,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
switch action {
|
switch action {
|
||||||
case "open_long", "open_short":
|
case "open_long", "open_short":
|
||||||
// Open position: create new position record
|
// Open position: create new position record
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
pos := &store.TraderPosition{
|
pos := &store.TraderPosition{
|
||||||
TraderID: at.id,
|
TraderID: at.id,
|
||||||
ExchangeID: at.exchangeID, // Exchange account UUID
|
ExchangeID: at.exchangeID, // Exchange account UUID
|
||||||
@@ -1956,9 +2022,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
EntryPrice: price,
|
EntryPrice: price,
|
||||||
EntryOrderID: orderID,
|
EntryOrderID: orderID,
|
||||||
EntryTime: time.Now(),
|
EntryTime: nowMs,
|
||||||
Leverage: leverage,
|
Leverage: leverage,
|
||||||
Status: "OPEN",
|
Status: "OPEN",
|
||||||
|
CreatedAt: nowMs,
|
||||||
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
if err := at.store.Position().Create(pos); err != nil {
|
if err := at.store.Position().Create(pos); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
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,
|
at.id, at.exchangeID, at.exchange,
|
||||||
symbol, side, action,
|
symbol, side, action,
|
||||||
quantity, price, fee, 0, // realizedPnL will be calculated
|
quantity, price, fee, 0, // realizedPnL will be calculated
|
||||||
time.Now(), orderID,
|
time.Now().UTC().UnixMilli(), orderID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -2029,8 +2097,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
|
|||||||
ReduceOnly: reduceOnly,
|
ReduceOnly: reduceOnly,
|
||||||
ClosePosition: reduceOnly,
|
ClosePosition: reduceOnly,
|
||||||
OrderAction: orderAction,
|
OrderAction: orderAction,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2071,7 +2139,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0, // Will be calculated for close orders
|
RealizedPnL: 0, // Will be calculated for close orders
|
||||||
IsMaker: false, // Market orders are usually taker
|
IsMaker: false, // Market orders are usually taker
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate realized PnL for close orders
|
// 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
|
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)
|
// 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)
|
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||||
@@ -776,6 +895,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
|||||||
return nil
|
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
|
// GetMarketPrice gets market price
|
||||||
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||||
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
|
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
|
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
|
// Helper functions
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && stringContains(s, substr)
|
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),
|
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||||
Symbol: income.Symbol,
|
Symbol: income.Symbol,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
Time: time.UnixMilli(income.Time),
|
Time: time.UnixMilli(income.Time).UTC(),
|
||||||
// Note: Income API doesn't provide price, quantity, side, fee
|
// Note: Income API doesn't provide price, quantity, side, fee
|
||||||
// For accurate data, use GetTradesForSymbol with specific symbol
|
// For accurate data, use GetTradesForSymbol with specific symbol
|
||||||
}
|
}
|
||||||
@@ -1167,7 +1380,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
|||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
Time: time.UnixMilli(at.Time),
|
Time: time.UnixMilli(at.Time).UTC(),
|
||||||
}
|
}
|
||||||
trades = append(trades, trade)
|
trades = append(trades, trade)
|
||||||
}
|
}
|
||||||
@@ -1210,7 +1423,7 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
|
|||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
Time: time.UnixMilli(at.Time),
|
Time: time.UnixMilli(at.Time).UTC(),
|
||||||
}
|
}
|
||||||
trades = append(trades, trade)
|
trades = append(trades, trade)
|
||||||
}
|
}
|
||||||
@@ -1244,3 +1457,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
|
|||||||
|
|
||||||
return symbols, nil
|
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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// syncState stores the last sync time for incremental sync
|
// syncState stores the last sync time (Unix ms) for incremental sync
|
||||||
var (
|
var (
|
||||||
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime
|
binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
|
||||||
binanceSyncStateMutex sync.RWMutex
|
binanceSyncStateMutex sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,43 +25,101 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
return fmt.Errorf("store is nil")
|
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()
|
binanceSyncStateMutex.RLock()
|
||||||
lastSyncTime, exists := binanceSyncState[exchangeID]
|
lastSyncTimeMs, exists := binanceSyncState[exchangeID]
|
||||||
binanceSyncStateMutex.RUnlock()
|
binanceSyncStateMutex.RUnlock()
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
if !exists {
|
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
|
logger.Infof("🔄 Syncing Binance trades from: %s (UTC) [ms: %d, now: %d]",
|
||||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"), lastSyncTimeMs, nowMs)
|
||||||
syncStartTime := time.Now()
|
|
||||||
|
|
||||||
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
|
|
||||||
|
|
||||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||||
orderStore := st.Order()
|
|
||||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
||||||
maxTradeIDs = make(map[string]int64)
|
maxTradeIDs = make(map[string]int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call)
|
// Step 2: Detect symbols to sync using multiple methods
|
||||||
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
// 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 {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err)
|
logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
|
||||||
// Fallback: only sync symbols with active positions
|
} else {
|
||||||
changedSymbols = t.getPositionSymbols()
|
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 {
|
if len(changedSymbols) == 0 {
|
||||||
logger.Infof("📭 No symbols with new trades to sync")
|
logger.Infof("📭 No symbols with new trades to sync")
|
||||||
// Update last sync time even if no changes
|
// DON'T update lastSyncTime to current time here!
|
||||||
binanceSyncStateMutex.Lock()
|
// Keep using the last actual trade time from DB to avoid creating gaps
|
||||||
binanceSyncState[exchangeID] = syncStartTime
|
// The lastSyncTimeMs from DB already has +1000ms buffer added
|
||||||
binanceSyncStateMutex.Unlock()
|
|
||||||
return nil
|
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)
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(allTrades, func(i, j int) bool {
|
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
|
// Process trades one by one
|
||||||
@@ -118,10 +171,12 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
posBuilder := store.NewPositionBuilder(positionStore)
|
posBuilder := store.NewPositionBuilder(positionStore)
|
||||||
syncedCount := 0
|
syncedCount := 0
|
||||||
|
|
||||||
|
skippedCount := 0
|
||||||
for _, trade := range allTrades {
|
for _, trade := range allTrades {
|
||||||
// Check if trade already exists
|
// Check if trade already exists
|
||||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||||
if err == nil && existing != nil {
|
if err == nil && existing != nil {
|
||||||
|
skippedCount++
|
||||||
continue // Trade already exists, skip
|
continue // Trade already exists, skip
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +200,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
// Normalize side
|
// Normalize side
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record
|
// Create order record - use Unix milliseconds UTC
|
||||||
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -162,9 +218,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: trade.Time,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: trade.Time,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -173,7 +229,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -190,7 +246,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -202,7 +258,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -210,11 +266,26 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncedCount++
|
syncedCount++
|
||||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
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.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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +349,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
|
|||||||
|
|
||||||
// StartOrderSync starts background order sync task for Binance
|
// StartOrderSync starts background order sync task for Binance
|
||||||
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
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)
|
ticker := time.NewTicker(interval)
|
||||||
go func() {
|
go func() {
|
||||||
for range ticker.C {
|
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,
|
FillQty: fillQty,
|
||||||
Fee: -fee, // Bitget returns negative fee
|
Fee: -fee, // Bitget returns negative fee
|
||||||
FeeAsset: fill.FeeCcy,
|
FeeAsset: fill.FeeCcy,
|
||||||
ExecTime: time.UnixMilli(cTime),
|
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||||
ProfitLoss: profit,
|
ProfitLoss: profit,
|
||||||
OrderType: "MARKET",
|
OrderType: "MARKET",
|
||||||
OrderAction: orderAction,
|
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 trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
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)
|
// 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
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
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{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -191,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
FilledQuantity: trade.FillQty,
|
FilledQuantity: trade.FillQty,
|
||||||
AvgFillPrice: trade.FillPrice,
|
AvgFillPrice: trade.FillPrice,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: trade.ExecTime,
|
FilledAt: execTimeMs,
|
||||||
CreatedAt: trade.ExecTime,
|
CreatedAt: execTimeMs,
|
||||||
UpdatedAt: trade.ExecTime,
|
UpdatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -202,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record
|
// Create fill record - use UTC time in milliseconds
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -219,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
CommissionAsset: trade.FeeAsset,
|
CommissionAsset: trade.FeeAsset,
|
||||||
RealizedPnL: trade.ProfitLoss,
|
RealizedPnL: trade.ProfitLoss,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: trade.ExecTime,
|
CreatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -231,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, trade.OrderAction,
|
symbol, positionSide, trade.OrderAction,
|
||||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||||
trade.ExecTime, trade.TradeID,
|
execTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1069,8 +1069,8 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
|
|||||||
|
|
||||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||||
record.EntryTime = time.UnixMilli(cTime)
|
record.EntryTime = time.UnixMilli(cTime).UTC()
|
||||||
record.ExitTime = time.UnixMilli(uTime)
|
record.ExitTime = time.UnixMilli(uTime).UTC()
|
||||||
|
|
||||||
record.CloseType = "unknown"
|
record.CloseType = "unknown"
|
||||||
records = append(records, record)
|
records = append(records, record)
|
||||||
@@ -1096,3 +1096,243 @@ func genBitgetClientOid() string {
|
|||||||
rand := time.Now().Nanosecond() % 100000
|
rand := time.Now().Nanosecond() % 100000
|
||||||
return fmt.Sprintf("nofx%d%05d", timestamp, rand)
|
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)
|
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
|
||||||
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
|
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
|
||||||
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
|
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
|
||||||
execTime := time.UnixMilli(execTimeMs)
|
execTime := time.UnixMilli(execTimeMs).UTC()
|
||||||
|
|
||||||
// Determine order action based on side and closedSize
|
// Determine order action based on side and closedSize
|
||||||
// If closedSize > 0, it's a close trade
|
// 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 trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
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)
|
// 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
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
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{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -240,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
FilledQuantity: trade.ExecQty,
|
FilledQuantity: trade.ExecQty,
|
||||||
AvgFillPrice: trade.ExecPrice,
|
AvgFillPrice: trade.ExecPrice,
|
||||||
Commission: trade.ExecFee,
|
Commission: trade.ExecFee,
|
||||||
FilledAt: trade.ExecTime,
|
FilledAt: execTimeMs,
|
||||||
CreatedAt: trade.ExecTime,
|
CreatedAt: execTimeMs,
|
||||||
UpdatedAt: trade.ExecTime,
|
UpdatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -251,7 +252,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record
|
// Create fill record - use UTC time
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -268,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.ClosedPnL,
|
RealizedPnL: trade.ClosedPnL,
|
||||||
IsMaker: trade.IsMaker,
|
IsMaker: trade.IsMaker,
|
||||||
CreatedAt: trade.ExecTime,
|
CreatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -280,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, trade.OrderAction,
|
symbol, positionSide, trade.OrderAction,
|
||||||
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
||||||
trade.ExecTime, trade.ExecID,
|
execTimeMs, trade.ExecID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1032,8 +1032,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
|||||||
RealizedPnL: closedPnL,
|
RealizedPnL: closedPnL,
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
Leverage: int(leverage),
|
Leverage: int(leverage),
|
||||||
EntryTime: time.UnixMilli(createdTime),
|
EntryTime: time.UnixMilli(createdTime).UTC(),
|
||||||
ExitTime: time.UnixMilli(updatedTime),
|
ExitTime: time.UnixMilli(updatedTime).UTC(),
|
||||||
OrderID: orderId,
|
OrderID: orderId,
|
||||||
CloseType: "unknown", // Bybit doesn't provide close type directly
|
CloseType: "unknown", // Bybit doesn't provide close type directly
|
||||||
ExchangeID: orderId, // Use orderId as exchange ID
|
ExchangeID: orderId, // Use orderId as exchange ID
|
||||||
@@ -1044,3 +1044,220 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
|||||||
|
|
||||||
return records, nil
|
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,
|
traderID, exchangeID, exchangeType,
|
||||||
trade.Symbol, trade.Side, trade.Action,
|
trade.Symbol, trade.Side, trade.Action,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
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 {
|
if err != nil {
|
||||||
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
"ETHUSDT", "LONG", "open_long",
|
"ETHUSDT", "LONG", "open_long",
|
||||||
0.1, 3500+float64(i*10), 0.5, 0,
|
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 {
|
if err != nil {
|
||||||
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
"ETHUSDT", "LONG", "close_long",
|
"ETHUSDT", "LONG", "close_long",
|
||||||
0.1, 3600+float64(i*10), 0.5, 10,
|
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 {
|
if err != nil {
|
||||||
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
"BTCUSDT", "LONG", "open_long",
|
"BTCUSDT", "LONG", "open_long",
|
||||||
0.01, 50000, 1.0, 0,
|
0.01, 50000, 1.0, 0,
|
||||||
time.Now(),
|
time.Now().UnixMilli(),
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
"BTCUSDT", "LONG", "close_long",
|
"BTCUSDT", "LONG", "close_long",
|
||||||
0.00999999, 51000, 1.0, 10,
|
0.00999999, 51000, 1.0, 10,
|
||||||
time.Now().Add(time.Second),
|
time.Now().Add(time.Second).UnixMilli(),
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
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)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -61,7 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
positionSide = "SHORT"
|
positionSide = "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order record
|
// Create order record - use Unix milliseconds UTC
|
||||||
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -78,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: trade.Time,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: trade.Time,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -89,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -106,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
||||||
CreatedAt: trade.Time,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -118,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "open_long",
|
symbol, "LONG", "open_long",
|
||||||
0.1, 3500, 0.5, 0,
|
0.1, 3500, 0.5, 0,
|
||||||
time.Now(), "order-1",
|
time.Now().UnixMilli(), "order-1",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process open long: %v", err)
|
t.Fatalf("Failed to process open long: %v", err)
|
||||||
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "close_long",
|
symbol, "LONG", "close_long",
|
||||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process close long: %v", err)
|
t.Fatalf("Failed to process close long: %v", err)
|
||||||
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "SHORT", "open_short",
|
symbol, "SHORT", "open_short",
|
||||||
0.05, 3500, 0.25, 0,
|
0.05, 3500, 0.25, 0,
|
||||||
time.Now(), "order-3",
|
time.Now().UnixMilli(), "order-3",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process open short: %v", err)
|
t.Fatalf("Failed to process open short: %v", err)
|
||||||
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "SHORT", "close_short",
|
symbol, "SHORT", "close_short",
|
||||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process close short: %v", err)
|
t.Fatalf("Failed to process close short: %v", err)
|
||||||
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "open_long",
|
symbol, "LONG", "open_long",
|
||||||
0.1, 3500, 0.5, 0,
|
0.1, 3500, 0.5, 0,
|
||||||
time.Now(), "order-5",
|
time.Now().UnixMilli(), "order-5",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process first open: %v", err)
|
t.Fatalf("Failed to process first open: %v", err)
|
||||||
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "open_long",
|
symbol, "LONG", "open_long",
|
||||||
0.1, 3600, 0.5, 0,
|
0.1, 3600, 0.5, 0,
|
||||||
time.Now(), "order-6",
|
time.Now().UnixMilli(), "order-6",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process add position: %v", err)
|
t.Fatalf("Failed to process add position: %v", err)
|
||||||
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "close_long",
|
symbol, "LONG", "close_long",
|
||||||
0.2, 3700, 1.0, 30.0,
|
0.2, 3700, 1.0, 30.0,
|
||||||
time.Now(), "order-7",
|
time.Now().UnixMilli(), "order-7",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process close: %v", err)
|
t.Fatalf("Failed to process close: %v", err)
|
||||||
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "open_long",
|
symbol, "LONG", "open_long",
|
||||||
1.0, 3500, 2.0, 0,
|
1.0, 3500, 2.0, 0,
|
||||||
time.Now(), "order-8",
|
time.Now().UnixMilli(), "order-8",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process open: %v", err)
|
t.Fatalf("Failed to process open: %v", err)
|
||||||
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, "LONG", "close_long",
|
symbol, "LONG", "close_long",
|
||||||
0.3, 3600, 0.6, 30.0,
|
0.3, 3600, 0.6, 30.0,
|
||||||
time.Now(), "order-9",
|
time.Now().UnixMilli(), "order-9",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to process partial close: %v", err)
|
t.Fatalf("Failed to process partial close: %v", err)
|
||||||
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
trade.symbol, trade.side, trade.action,
|
trade.symbol, trade.side, trade.action,
|
||||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
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 {
|
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{
|
action := hyperliquid.OrderAction{
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Orders: []hyperliquid.OrderWire{orderWire},
|
Orders: []hyperliquid.OrderWire{orderWire},
|
||||||
Grouping: "na",
|
Grouping: "na",
|
||||||
Builder: &hyperliquid.BuilderInfo{
|
Builder: nil,
|
||||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
|
||||||
Fee: 10,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the action
|
// 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{
|
action := hyperliquid.OrderAction{
|
||||||
Type: "order",
|
Type: "order",
|
||||||
Orders: []hyperliquid.OrderWire{orderWire},
|
Orders: []hyperliquid.OrderWire{orderWire},
|
||||||
Grouping: "na",
|
Grouping: "na",
|
||||||
Builder: &hyperliquid.BuilderInfo{
|
Builder: nil,
|
||||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
|
||||||
Fee: 10,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign the action
|
// Sign the action
|
||||||
@@ -2070,7 +2064,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
|||||||
Quantity: qty,
|
Quantity: qty,
|
||||||
RealizedPnL: pnl,
|
RealizedPnL: pnl,
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
Time: time.UnixMilli(fill.Time),
|
Time: time.UnixMilli(fill.Time).UTC(),
|
||||||
}
|
}
|
||||||
trades = append(trades, trade)
|
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
|
// defaultBuilder is the builder info for order routing
|
||||||
var defaultBuilder = &hyperliquid.BuilderInfo{
|
// Set to nil to avoid requiring builder fee approval
|
||||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
//
|
||||||
Fee: 10,
|
// 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
|
package trader
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nofx/logger"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// ClosedPnLRecord represents a single closed position record from exchange
|
// ClosedPnLRecord represents a single closed position record from exchange
|
||||||
type ClosedPnLRecord struct {
|
type ClosedPnLRecord struct {
|
||||||
@@ -94,4 +98,133 @@ type Trader interface {
|
|||||||
// limit: max number of records to return
|
// limit: max number of records to return
|
||||||
// Returns accurate exit price, fees, and close reason for positions closed externally
|
// Returns accurate exit price, fees, and close reason for positions closed externally
|
||||||
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
|
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