mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-23 06:01:43 +08:00
Compare commits
72 Commits
release/st
...
ai-grid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b56a98bf | ||
|
|
e5f69bfea6 | ||
|
|
e198498f3a | ||
|
|
aa6168afe3 | ||
|
|
917a16381f | ||
|
|
7db84d57d3 | ||
|
|
95486173f7 | ||
|
|
ee081ebc85 | ||
|
|
993db33466 | ||
|
|
7f24a90851 | ||
|
|
19698529b8 | ||
|
|
35fcf17df4 | ||
|
|
2b1012b85b | ||
|
|
826276f58c | ||
|
|
587efba52c | ||
|
|
4642671e77 | ||
|
|
bd8cc9c176 | ||
|
|
7d7493b576 | ||
|
|
cbe753b9e6 | ||
|
|
5c79aa451e | ||
|
|
0a2c62885b | ||
|
|
ac25dd334e | ||
|
|
f4cdf2e532 | ||
|
|
f6411f05ba | ||
|
|
38be361eca | ||
|
|
584bfae699 | ||
|
|
73789f7fb7 | ||
|
|
65f333e73c | ||
|
|
1454ad3112 | ||
|
|
ec81384b7a | ||
|
|
c161632e2b | ||
|
|
8ef6045f9d | ||
|
|
d7d9dc5c42 | ||
|
|
90509ae783 | ||
|
|
937527281e | ||
|
|
2bc45827f3 | ||
|
|
68e8a6e4b0 | ||
|
|
aa7aa94275 | ||
|
|
13189fa3aa | ||
|
|
33cf09e7fe | ||
|
|
ef91bec2dd | ||
|
|
2fcbdbab36 | ||
|
|
1786f0ff53 | ||
|
|
1b47249d57 | ||
|
|
502801777f | ||
|
|
b10b9ec1a7 | ||
|
|
c1def0e2c2 | ||
|
|
5fb26c17dc | ||
|
|
705aa641b0 | ||
|
|
2f88205231 | ||
|
|
e92222950a | ||
|
|
138943d6fb | ||
|
|
b36ab27b65 | ||
|
|
5e65ae7077 | ||
|
|
c0c89d7534 | ||
|
|
3b2a3f4e76 | ||
|
|
c8458ec79c | ||
|
|
aee096ab1e | ||
|
|
165c0b1b5d | ||
|
|
4c097f7190 | ||
|
|
ea763a2471 | ||
|
|
6e6bdf1e57 | ||
|
|
f0b4913ad6 | ||
|
|
29cd79c626 | ||
|
|
7db37ade1c | ||
|
|
4804cfcb05 | ||
|
|
799d8b9c2e | ||
|
|
5c4c9cdc99 | ||
|
|
8b86d4d85c | ||
|
|
962df5c3ed | ||
|
|
9f3de6e3c0 | ||
|
|
5c9e134e99 |
242
.github/PR_TITLE_GUIDE.md
vendored
242
.github/PR_TITLE_GUIDE.md
vendored
@@ -1,16 +1,16 @@
|
||||
# PR 标题指南
|
||||
# PR Title Guide
|
||||
|
||||
## 📋 概述
|
||||
## 📋 Overview
|
||||
|
||||
我们使用 **Conventional Commits** 格式来保持 PR 标题的一致性,但这是**建议性的**,不会阻止你的 PR 被合并。
|
||||
We use the **Conventional Commits** format to maintain consistency in PR titles, but this is **recommended**, not mandatory. It will not prevent your PR from being merged.
|
||||
|
||||
## ✅ 推荐格式
|
||||
## ✅ Recommended Format
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
```
|
||||
|
||||
### 示例
|
||||
### Examples
|
||||
|
||||
```
|
||||
feat(trader): add new trading strategy
|
||||
@@ -22,63 +22,63 @@ ci(workflow): improve GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
## 📖 详细说明
|
||||
## 📖 Detailed Guide
|
||||
|
||||
### Type(类型)- 必需
|
||||
### Type - Required
|
||||
|
||||
描述这次变更的类型:
|
||||
Describes the type of change:
|
||||
|
||||
| Type | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新功能 | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug 修复 | `fix(api): handle null response` |
|
||||
| `docs` | 文档变更 | `docs: update installation guide` |
|
||||
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` |
|
||||
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` |
|
||||
| `test` | 添加或修改测试 | `test(trader): add unit tests` |
|
||||
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` |
|
||||
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` |
|
||||
| `security` | 安全相关修复 | `security: update vulnerable dependencies` |
|
||||
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` |
|
||||
| Type | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| `feat` | New feature | `feat(trader): add stop-loss feature` |
|
||||
| `fix` | Bug fix | `fix(api): handle null response` |
|
||||
| `docs` | Documentation change | `docs: update installation guide` |
|
||||
| `style` | Code formatting (no functional change) | `style: format code with prettier` |
|
||||
| `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
|
||||
| `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
|
||||
| `test` | Add or modify tests | `test(trader): add unit tests` |
|
||||
| `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
|
||||
| `ci` | CI/CD related changes | `ci: add test coverage report` |
|
||||
| `security` | Security fixes | `security: update vulnerable dependencies` |
|
||||
| `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
|
||||
|
||||
### Scope(范围)- 可选
|
||||
### Scope - Optional
|
||||
|
||||
描述这次变更影响的范围:
|
||||
Describes the area affected by the change:
|
||||
|
||||
| Scope | 说明 |
|
||||
|-------|------|
|
||||
| `exchange` | 交易所相关 |
|
||||
| `trader` | 交易员/交易策略 |
|
||||
| `ai` | AI 模型相关 |
|
||||
| `api` | API 接口 |
|
||||
| `ui` | 用户界面 |
|
||||
| `frontend` | 前端代码 |
|
||||
| `backend` | 后端代码 |
|
||||
| `security` | 安全相关 |
|
||||
| `deps` | 依赖项 |
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `exchange` | Exchange-related |
|
||||
| `trader` | Trader/trading strategy |
|
||||
| `ai` | AI model related |
|
||||
| `api` | API interface |
|
||||
| `ui` | User interface |
|
||||
| `frontend` | Frontend code |
|
||||
| `backend` | Backend code |
|
||||
| `security` | Security related |
|
||||
| `deps` | Dependencies |
|
||||
| `workflow` | GitHub Actions workflows |
|
||||
| `github` | GitHub 配置 |
|
||||
| `github` | GitHub configuration |
|
||||
| `actions` | GitHub Actions |
|
||||
| `config` | 配置文件 |
|
||||
| `docker` | Docker 相关 |
|
||||
| `build` | 构建相关 |
|
||||
| `release` | 发布相关 |
|
||||
| `config` | Configuration files |
|
||||
| `docker` | Docker related |
|
||||
| `build` | Build related |
|
||||
| `release` | Release related |
|
||||
|
||||
**注意:** 如果变更影响多个范围,可以省略 scope 或选择最主要的。
|
||||
**Note:** If the change affects multiple scopes, you can omit the scope or choose the most relevant one.
|
||||
|
||||
### Description(描述)- 必需
|
||||
### Description - Required
|
||||
|
||||
- 使用现在时态("add" 而不是 "added")
|
||||
- 首字母小写
|
||||
- 结尾不加句号
|
||||
- 简洁明了地描述变更内容
|
||||
- Use present tense ("add" not "added")
|
||||
- Start with lowercase
|
||||
- No period at the end
|
||||
- Concisely describe what changed
|
||||
|
||||
---
|
||||
|
||||
## 🎯 完整示例
|
||||
## 🎯 Complete Examples
|
||||
|
||||
### ✅ 好的 PR 标题
|
||||
### ✅ Good PR Titles
|
||||
|
||||
```
|
||||
feat(trader): add risk management system
|
||||
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
|
||||
build(docker): optimize Docker image size
|
||||
```
|
||||
|
||||
### ⚠️ 需要改进的标题
|
||||
### ⚠️ Titles That Need Improvement
|
||||
|
||||
| 不好的标题 | 问题 | 改进后 |
|
||||
|-----------|------|--------|
|
||||
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | 完全不符合格式 | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` |
|
||||
| Poor Title | Issue | Improved |
|
||||
|-----------|-------|----------|
|
||||
| `update code` | Too vague | `refactor(trader): simplify order execution logic` |
|
||||
| `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
|
||||
| `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
|
||||
| `changes` | Doesn't follow format | `chore: update dependencies` |
|
||||
| `feat: Added new trading algo` | Wrong tense | `feat(trader): add new trading algorithm` |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 自动检查行为
|
||||
## 🤖 Automated Check Behavior
|
||||
|
||||
### 当 PR 标题不符合格式时
|
||||
### When PR Title Doesn't Follow Format
|
||||
|
||||
1. **不会阻止合并** ✅
|
||||
- 检查会标记为"建议"
|
||||
- PR 仍然可以被审查和合并
|
||||
1. **Won't block merging** ✅
|
||||
- Check is marked as "advisory"
|
||||
- PR can still be reviewed and merged
|
||||
|
||||
2. **会收到友好提示** 💬
|
||||
- 机器人会在 PR 中留言
|
||||
- 提供格式说明和示例
|
||||
- 建议如何改进标题
|
||||
2. **Provides friendly reminder** 💬
|
||||
- Bot will comment on the PR
|
||||
- Provides format guidance and examples
|
||||
- Suggests how to improve the title
|
||||
|
||||
3. **可以随时更新** 🔄
|
||||
- 更新 PR 标题后会重新检查
|
||||
- 无需关闭和重新打开 PR
|
||||
3. **Can be updated anytime** 🔄
|
||||
- Re-checks after updating PR title
|
||||
- No need to close and reopen PR
|
||||
|
||||
### 示例评论
|
||||
### Example Comment
|
||||
|
||||
如果你的 PR 标题是 `update workflow`,你会收到这样的评论:
|
||||
If your PR title is `update workflow`, you'll receive a comment like this:
|
||||
|
||||
```markdown
|
||||
## ⚠️ PR Title Format Suggestion
|
||||
@@ -157,11 +157,11 @@ Your PR can still be reviewed and merged.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置详情
|
||||
## 🔧 Configuration Details
|
||||
|
||||
### 支持的 Types
|
||||
### Supported Types
|
||||
|
||||
在 `.github/workflows/pr-checks.yml` 中配置:
|
||||
Configured in `.github/workflows/pr-checks.yml`:
|
||||
|
||||
```yaml
|
||||
types: |
|
||||
@@ -178,7 +178,7 @@ types: |
|
||||
build
|
||||
```
|
||||
|
||||
### 支持的 Scopes
|
||||
### Supported Scopes
|
||||
|
||||
```yaml
|
||||
scopes: |
|
||||
@@ -200,38 +200,38 @@ scopes: |
|
||||
release
|
||||
```
|
||||
|
||||
### 添加新的 Scope
|
||||
### Adding New Scopes
|
||||
|
||||
如果你需要添加新的 scope,请:
|
||||
If you need to add a new scope:
|
||||
|
||||
1. 在 `.github/workflows/pr-checks.yml` 的 `scopes` 部分添加
|
||||
2. 在 `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选)
|
||||
3. 更新本文档
|
||||
1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
|
||||
2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
|
||||
3. Update this documentation
|
||||
|
||||
---
|
||||
|
||||
## 📚 为什么使用 Conventional Commits?
|
||||
## 📚 Why Use Conventional Commits?
|
||||
|
||||
### 优点
|
||||
### Benefits
|
||||
|
||||
1. **自动化 Changelog** 📝
|
||||
- 可以自动生成版本更新日志
|
||||
- 清晰地分类各种变更
|
||||
1. **Automated Changelog** 📝
|
||||
- Automatically generate version changelogs
|
||||
- Clearly categorize different types of changes
|
||||
|
||||
2. **语义化版本** 🔢
|
||||
- `feat` → MINOR 版本(1.1.0)
|
||||
- `fix` → PATCH 版本(1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR 版本(2.0.0)
|
||||
2. **Semantic Versioning** 🔢
|
||||
- `feat` → MINOR version (1.1.0)
|
||||
- `fix` → PATCH version (1.0.1)
|
||||
- `BREAKING CHANGE` → MAJOR version (2.0.0)
|
||||
|
||||
3. **更好的可读性** 👀
|
||||
- 一眼看出 PR 的目的
|
||||
- 更容易浏览 Git 历史
|
||||
3. **Better Readability** 👀
|
||||
- Understand PR purpose at a glance
|
||||
- Easier to browse Git history
|
||||
|
||||
4. **团队协作** 🤝
|
||||
- 统一的提交风格
|
||||
- 降低沟通成本
|
||||
4. **Team Collaboration** 🤝
|
||||
- Unified commit style
|
||||
- Reduces communication overhead
|
||||
|
||||
### 示例:自动生成的 Changelog
|
||||
### Example: Auto-generated Changelog
|
||||
|
||||
```markdown
|
||||
## v1.2.0 (2025-11-02)
|
||||
@@ -250,9 +250,9 @@ scopes: |
|
||||
|
||||
---
|
||||
|
||||
## 🎓 学习资源
|
||||
## 🎓 Learning Resources
|
||||
|
||||
- **Conventional Commits 官网:** https://www.conventionalcommits.org/
|
||||
- **Conventional Commits:** https://www.conventionalcommits.org/
|
||||
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
|
||||
- **Semantic Versioning:** https://semver.org/
|
||||
|
||||
@@ -260,33 +260,33 @@ scopes: |
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
### Q: 我必须遵循这个格式吗?
|
||||
### Q: Must I follow this format?
|
||||
|
||||
**A:** 不必须。这是建议性的,不会阻止你的 PR 被合并。但遵循格式可以提高项目的可维护性。
|
||||
**A:** No. This is recommended but not mandatory. It won't block your PR from being merged. However, following the format improves project maintainability.
|
||||
|
||||
### Q: 如果我忘记了怎么办?
|
||||
### Q: What if I forget?
|
||||
|
||||
**A:** 机器人会在 PR 中提醒你,你可以随时更新标题。
|
||||
**A:** The bot will remind you in the PR comments. You can update the title anytime.
|
||||
|
||||
### Q: 我可以在一个 PR 中做多种类型的变更吗?
|
||||
### Q: Can I make multiple types of changes in one PR?
|
||||
|
||||
**A:** 可以,但建议:
|
||||
- 选择最主要的类型
|
||||
- 或者考虑拆分成多个 PR(更易于审查)
|
||||
**A:** Yes, but it's recommended to:
|
||||
- Choose the most significant type
|
||||
- Or consider splitting into multiple PRs (easier to review)
|
||||
|
||||
### Q: Scope 可以省略吗?
|
||||
### Q: Can I omit the scope?
|
||||
|
||||
**A:** 可以。`requireScope: false` 表示 scope 是可选的。
|
||||
**A:** Yes. `requireScope: false` means scope is optional.
|
||||
|
||||
示例:`docs: update README` (没有 scope 也可以)
|
||||
Example: `docs: update README` (no scope is fine)
|
||||
|
||||
### Q: 我想添加新的 type 或 scope,怎么做?
|
||||
### Q: How do I add a new type or scope?
|
||||
|
||||
**A:** 提一个 PR 修改 `.github/workflows/pr-checks.yml`,并在本文档中说明新增项的用途。
|
||||
**A:** Submit a PR to modify `.github/workflows/pr-checks.yml` and document the purpose of the new item in this guide.
|
||||
|
||||
### Q: Breaking Changes 怎么表示?
|
||||
### Q: How do I indicate Breaking Changes?
|
||||
|
||||
**A:** 在描述中添加 `BREAKING CHANGE:` 或在 type 后加 `!`:
|
||||
**A:** Add `BREAKING CHANGE:` in the description or add `!` after the type:
|
||||
|
||||
```
|
||||
feat!: remove deprecated API
|
||||
@@ -297,9 +297,9 @@ BREAKING CHANGE: The old /auth endpoint is removed
|
||||
|
||||
---
|
||||
|
||||
## 📊 统计
|
||||
## 📊 Statistics
|
||||
|
||||
想看项目的 commit 类型分布?运行:
|
||||
Want to see the commit type distribution in your project? Run:
|
||||
|
||||
```bash
|
||||
git log --oneline --no-merges | \
|
||||
@@ -309,14 +309,14 @@ git log --oneline --no-merges | \
|
||||
|
||||
---
|
||||
|
||||
## ✅ 快速检查清单
|
||||
## ✅ Quick Checklist
|
||||
|
||||
在提交 PR 前,检查你的标题是否:
|
||||
Before submitting a PR, check if your title:
|
||||
|
||||
- [ ] 包含有效的 type(feat, fix, docs 等)
|
||||
- [ ] 使用小写字母开头
|
||||
- [ ] 使用现在时态("add" 而不是 "added")
|
||||
- [ ] 简洁明了(最好在 50 字符内)
|
||||
- [ ] 准确描述了变更内容
|
||||
- [ ] Contains a valid type (feat, fix, docs, etc.)
|
||||
- [ ] Starts with lowercase
|
||||
- [ ] Uses present tense ("add" not "added")
|
||||
- [ ] Is concise (preferably under 50 characters)
|
||||
- [ ] Accurately describes the change
|
||||
|
||||
**记住:** 这些都是建议,不是强制要求!
|
||||
**Remember:** These are recommendations, not requirements!
|
||||
|
||||
100
.github/PULL_REQUEST_TEMPLATE.md
vendored
100
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,104 +1,100 @@
|
||||
# Pull Request | PR 提交
|
||||
# Pull Request
|
||||
|
||||
> **📋 选择专用模板 | Choose Specialized Template**
|
||||
> **📋 Choose Specialized Template**
|
||||
>
|
||||
> 我们现在提供了针对不同类型PR的专用模板,帮助你更快速地填写PR信息:
|
||||
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
|
||||
>
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes
|
||||
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
|
||||
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
|
||||
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
|
||||
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
|
||||
>
|
||||
> **如何使用?| How to use?**
|
||||
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
|
||||
> **How to use?**
|
||||
> - When creating a PR, add `?template=backend.md` or other template name to the URL
|
||||
> - 或者直接复制粘贴对应模板的内容
|
||||
> - Or simply copy and paste the content from the corresponding template
|
||||
|
||||
---
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
<!-- List the specific changes made -->
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
## 📚 Additional Notes
|
||||
|
||||
**English:** | **中文:**
|
||||
<!-- Any additional information or context -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
198
.github/PULL_REQUEST_TEMPLATE/README.md
vendored
@@ -1,213 +1,177 @@
|
||||
# PR Templates | PR 模板
|
||||
# PR Templates
|
||||
|
||||
## 📋 模板概述 | Template Overview
|
||||
## 📋 Template Overview
|
||||
|
||||
我们提供了4种针对不同类型PR的专用模板,帮助贡献者快速填写PR信息:
|
||||
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
|
||||
|
||||
### 1. 🔧 Backend Template | 后端模板
|
||||
**文件:** `backend.md`
|
||||
### 1. 🔧 Backend Template
|
||||
**File:** `backend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- Go代码变更 | Go code changes
|
||||
- API端点开发 | API endpoint development
|
||||
- 交易逻辑实现 | Trading logic implementation
|
||||
- 后端性能优化 | Backend performance optimization
|
||||
- 数据库相关改动 | Database-related changes
|
||||
**Use for:**
|
||||
- Go code changes
|
||||
- API endpoint development
|
||||
- Trading logic implementation
|
||||
- Backend performance optimization
|
||||
- Database-related changes
|
||||
|
||||
**包含 | Includes:**
|
||||
- Go测试环境配置 | Go test environment
|
||||
- 安全考虑检查 | Security considerations
|
||||
- 性能影响评估 | Performance impact assessment
|
||||
- `go fmt` 和 `go build` 检查 | `go fmt` and `go build` checks
|
||||
**Includes:**
|
||||
- Go test environment
|
||||
- Security considerations
|
||||
- Performance impact assessment
|
||||
- `go fmt` and `go build` checks
|
||||
|
||||
### 2. 🎨 Frontend Template | 前端模板
|
||||
**文件:** `frontend.md`
|
||||
### 2. 🎨 Frontend Template
|
||||
**File:** `frontend.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- UI/UX变更 | UI/UX changes
|
||||
- React/Vue组件开发 | React/Vue component development
|
||||
- 前端样式更新 | Frontend styling updates
|
||||
- 浏览器兼容性修复 | Browser compatibility fixes
|
||||
- 前端性能优化 | Frontend performance optimization
|
||||
**Use for:**
|
||||
- UI/UX changes
|
||||
- React/Vue component development
|
||||
- Frontend styling updates
|
||||
- Browser compatibility fixes
|
||||
- Frontend performance optimization
|
||||
|
||||
**包含 | Includes:**
|
||||
- 截图/演示要求 | Screenshots/demo requirements
|
||||
- 浏览器测试清单 | Browser testing checklist
|
||||
- 国际化检查 | Internationalization checks
|
||||
- 响应式设计验证 | Responsive design verification
|
||||
- `npm run lint` 和 `npm run build` 检查 | Linting and build checks
|
||||
**Includes:**
|
||||
- Screenshots/demo requirements
|
||||
- Browser testing checklist
|
||||
- Internationalization checks
|
||||
- Responsive design verification
|
||||
- `npm run lint` and `npm run build` checks
|
||||
|
||||
### 3. 📝 Documentation Template | 文档模板
|
||||
**文件:** `docs.md`
|
||||
### 3. 📝 Documentation Template
|
||||
**File:** `docs.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- README更新 | README updates
|
||||
- API文档编写 | API documentation
|
||||
- 教程和指南 | Tutorials and guides
|
||||
- 代码注释改进 | Code comment improvements
|
||||
- 翻译工作 | Translation work
|
||||
**Use for:**
|
||||
- README updates
|
||||
- API documentation
|
||||
- Tutorials and guides
|
||||
- Code comment improvements
|
||||
- Translation work
|
||||
|
||||
**包含 | Includes:**
|
||||
- 文档类型分类 | Documentation type classification
|
||||
- 内容质量检查 | Content quality checks
|
||||
- 双语要求(中英文)| Bilingual requirements (EN/CN)
|
||||
- 链接有效性验证 | Link validity verification
|
||||
**Includes:**
|
||||
- Documentation type classification
|
||||
- Content quality checks
|
||||
- Bilingual requirements (EN/CN)
|
||||
- Link validity verification
|
||||
|
||||
### 4. 📦 General Template | 通用模板
|
||||
**文件:** `general.md`
|
||||
### 4. 📦 General Template
|
||||
**File:** `general.md`
|
||||
|
||||
**适用于 | Use for:**
|
||||
- 混合类型变更 | Mixed-type changes
|
||||
- 跨多个领域的PR | Cross-domain PRs
|
||||
- 构建配置变更 | Build configuration changes
|
||||
- 依赖更新 | Dependency updates
|
||||
- 不确定使用哪个模板时 | When unsure which template to use
|
||||
**Use for:**
|
||||
- Mixed-type changes
|
||||
- Cross-domain PRs
|
||||
- Build configuration changes
|
||||
- Dependency updates
|
||||
- When unsure which template to use
|
||||
|
||||
## 🤖 自动模板建议 | Automatic Template Suggestion
|
||||
## 🤖 Automatic Template Suggestion
|
||||
|
||||
我们的GitHub Action会自动分析你的PR并建议最合适的模板:
|
||||
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
|
||||
|
||||
### 工作原理 | How it works:
|
||||
### How it works:
|
||||
|
||||
1. **文件分析 | File Analysis**
|
||||
- 检测PR中所有变更的文件类型
|
||||
1. **File Analysis**
|
||||
- Detects all changed file types in the PR
|
||||
|
||||
2. **智能判断 | Smart Detection**
|
||||
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
|
||||
2. **Smart Detection**
|
||||
- If >50% are `.go` files → Suggests **Backend template**
|
||||
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
|
||||
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
|
||||
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
|
||||
- If >70% are `.md` files → Suggests **Documentation template**
|
||||
|
||||
3. **自动评论 | Auto-comment**
|
||||
- 如果检测到你使用了默认模板,但应该用专用模板
|
||||
3. **Auto-comment**
|
||||
- If it detects you're using the default template but should use a specialized one
|
||||
- 会自动添加友好的评论建议
|
||||
- It will automatically add a friendly comment suggestion
|
||||
|
||||
4. **自动标签 | Auto-labeling**
|
||||
- 自动添加对应的标签:`backend`、`frontend`、`documentation`
|
||||
4. **Auto-labeling**
|
||||
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
|
||||
|
||||
## 📖 使用方法 | How to Use
|
||||
## 📖 How to Use
|
||||
|
||||
### 方法1: URL参数(推荐) | Method 1: URL Parameter (Recommended)
|
||||
### Method 1: URL Parameter (Recommended)
|
||||
|
||||
创建PR时,在URL末尾添加模板参数:
|
||||
When creating a PR, add the template parameter to the URL:
|
||||
|
||||
```
|
||||
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
|
||||
```
|
||||
|
||||
替换 `backend.md` 为:
|
||||
Replace `backend.md` with:
|
||||
- `backend.md` - 后端模板 | Backend template
|
||||
- `frontend.md` - 前端模板 | Frontend template
|
||||
- `docs.md` - 文档模板 | Documentation template
|
||||
- `general.md` - 通用模板 | General template
|
||||
- `backend.md` - Backend template
|
||||
- `frontend.md` - Frontend template
|
||||
- `docs.md` - Documentation template
|
||||
- `general.md` - General template
|
||||
|
||||
### 方法2: 手动选择 | Method 2: Manual Selection
|
||||
### Method 2: Manual Selection
|
||||
|
||||
1. 创建PR时,默认模板会显示
|
||||
When creating a PR, the default template will be shown
|
||||
1. When creating a PR, the default template will be shown
|
||||
|
||||
2. 根据顶部的指引链接,点击查看对应的模板
|
||||
Follow the guidance links at the top to view the corresponding template
|
||||
2. Follow the guidance links at the top to view the corresponding template
|
||||
|
||||
3. 复制模板内容到PR描述中
|
||||
Copy the template content into the PR description
|
||||
3. Copy the template content into the PR description
|
||||
|
||||
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
|
||||
### Method 3: Follow Auto-suggestion
|
||||
|
||||
1. 使用任何模板创建PR
|
||||
Create a PR with any template
|
||||
1. Create a PR with any template
|
||||
|
||||
2. GitHub Action会自动分析并评论建议
|
||||
GitHub Action will automatically analyze and comment with a suggestion
|
||||
2. GitHub Action will automatically analyze and comment with a suggestion
|
||||
|
||||
3. 根据建议更新PR描述
|
||||
Update the PR description based on the suggestion
|
||||
3. Update the PR description based on the suggestion
|
||||
|
||||
## 🎯 最佳实践 | Best Practices
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **提前选择 | Choose in Advance**
|
||||
- 在创建PR前确定变更类型
|
||||
1. **Choose in Advance**
|
||||
- Determine the change type before creating the PR
|
||||
|
||||
2. **完整填写 | Complete Filling**
|
||||
- 不要跳过必填项(标记为 required)
|
||||
2. **Complete Filling**
|
||||
- Don't skip required items
|
||||
|
||||
3. **保持简洁 | Keep it Concise**
|
||||
- 描述清晰但简洁
|
||||
3. **Keep it Concise**
|
||||
- Keep descriptions clear but concise
|
||||
|
||||
4. **添加截图 | Add Screenshots**
|
||||
- 对于UI变更,务必添加截图
|
||||
4. **Add Screenshots**
|
||||
- For UI changes, always add screenshots
|
||||
|
||||
5. **测试证明 | Test Evidence**
|
||||
- 提供测试通过的证据
|
||||
5. **Test Evidence**
|
||||
- Provide evidence that tests pass
|
||||
|
||||
## 🔧 自定义 | Customization
|
||||
## 🔧 Customization
|
||||
|
||||
如果需要修改模板或自动检测逻辑:
|
||||
If you need to modify templates or auto-detection logic:
|
||||
|
||||
1. **修改模板** | **Modify Templates**
|
||||
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
|
||||
1. **Modify Templates**
|
||||
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
|
||||
|
||||
2. **调整检测阈值** | **Adjust Detection Threshold**
|
||||
- 编辑 `.github/workflows/pr-template-suggester.yml`
|
||||
2. **Adjust Detection Threshold**
|
||||
- Edit `.github/workflows/pr-template-suggester.yml`
|
||||
- 修改文件类型占比阈值(当前:50%后端,50%前端,70%文档)
|
||||
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
|
||||
|
||||
3. **添加新模板** | **Add New Template**
|
||||
- 在 `PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
|
||||
3. **Add New Template**
|
||||
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
|
||||
- 更新工作流以支持新的文件类型检测
|
||||
- Update the workflow to support new file type detection
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
|
||||
**Q: My PR has both frontend and backend code, which template should I use?**
|
||||
|
||||
A: 使用**通用模板**(`general.md`),或选择主要变更类型的模板。
|
||||
A: Use the **General template** (`general.md`), or choose the template for the primary change type.
|
||||
|
||||
---
|
||||
|
||||
**Q: 自动建议的模板不合适怎么办?**
|
||||
**Q: What if the automatically suggested template is not suitable?**
|
||||
|
||||
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
|
||||
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
|
||||
|
||||
---
|
||||
|
||||
**Q: 可以不使用任何模板吗?**
|
||||
**Q: Can I not use any template?**
|
||||
|
||||
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
|
||||
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
|
||||
|
||||
---
|
||||
|
||||
**Q: 如何禁用自动模板建议?**
|
||||
**Q: How to disable automatic template suggestions?**
|
||||
|
||||
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
|
||||
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
|
||||
|
||||
---
|
||||
|
||||
🌟 **感谢使用我们的PR模板系统!| Thank you for using our PR template system!**
|
||||
🌟 **Thank you for using our PR template system!**
|
||||
|
||||
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
125
.github/PULL_REQUEST_TEMPLATE/backend.md
vendored
@@ -1,121 +1,116 @@
|
||||
# Pull Request - Backend | 后端 PR
|
||||
# Pull Request - Backend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] 🔒 Security fix
|
||||
- [ ] 🔧 Build/config change
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Go Version | Go 版本:**
|
||||
- **Exchange | 交易所:** [if applicable | 如适用]
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Go Version:**
|
||||
- **Exchange:** [if applicable]
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
|
||||
- [ ] Unit tests pass | 单元测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested locally
|
||||
- [ ] Tested on testnet (for exchange integration)
|
||||
- [ ] Unit tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
### Test Results | 测试结果
|
||||
### Test Results
|
||||
```
|
||||
Test output here | 测试输出
|
||||
Test output here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Considerations | 安全考虑
|
||||
## 🔒 Security Considerations
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
|
||||
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
|
||||
- [ ] Sensitive data is encrypted | 敏感数据已加密
|
||||
- [ ] N/A (not security-related) | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] No SQL injection vulnerabilities
|
||||
- [ ] Authentication/authorization properly handled
|
||||
- [ ] Sensitive data is encrypted
|
||||
- [ ] N/A (not security-related)
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Impact | 性能影响
|
||||
## ⚡ Performance Impact
|
||||
|
||||
- [ ] No significant performance impact | 无显著性能影响
|
||||
- [ ] Performance improved | 性能提升
|
||||
- [ ] Performance may be impacted (explain below) | 性能可能受影响
|
||||
- [ ] No significant performance impact
|
||||
- [ ] Performance improved
|
||||
- [ ] Performance may be impacted (explain below)
|
||||
|
||||
**If impacted, explain | 如果受影响,请说明:**
|
||||
**If impacted, explain:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
|
||||
- [ ] Ran `go fmt` | 已运行 `go fmt`
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code compiles successfully (`go build`)
|
||||
- [ ] Ran `go fmt`
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
- [ ] Updated API documentation (if applicable)
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
94
.github/PULL_REQUEST_TEMPLATE/docs.md
vendored
@@ -1,97 +1,91 @@
|
||||
# Pull Request - Documentation | 文档 PR
|
||||
# Pull Request - Documentation
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description`
|
||||
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
> **💡 Tip:** Recommended PR title format `docs(scope): description`
|
||||
> Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📚 Type of Documentation | 文档类型
|
||||
## 📚 Type of Documentation
|
||||
|
||||
- [ ] 📖 README update | README 更新
|
||||
- [ ] 📋 API documentation | API 文档
|
||||
- [ ] 🎓 Tutorial/Guide | 教程/指南
|
||||
- [ ] 📝 Code comments | 代码注释
|
||||
- [ ] 🔧 Configuration docs | 配置文档
|
||||
- [ ] 🐛 Fix typo/error | 修复拼写/错误
|
||||
- [ ] 🌍 Translation | 翻译
|
||||
- [ ] 📖 README update
|
||||
- [ ] 📋 API documentation
|
||||
- [ ] 🎓 Tutorial/Guide
|
||||
- [ ] 📝 Code comments
|
||||
- [ ] 🔧 Configuration docs
|
||||
- [ ] 🐛 Fix typo/error
|
||||
- [ ] 🌍 Translation
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots (if applicable) | 截图(如适用)
|
||||
## 📸 Screenshots (if applicable)
|
||||
|
||||
<!-- For documentation with images, diagrams, or UI examples -->
|
||||
<!-- 用于包含图片、图表或 UI 示例的文档 -->
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] English version complete | 英文版本完整
|
||||
- [ ] Chinese version complete | 中文版本完整
|
||||
- [ ] Both versions are consistent | 两个版本内容一致
|
||||
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
|
||||
- [ ] English version complete
|
||||
- [ ] Chinese version complete
|
||||
- [ ] Both versions are consistent
|
||||
- [ ] N/A (only one language needed)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Content Quality | 内容质量
|
||||
- [ ] Information is accurate and up-to-date | 信息准确且最新
|
||||
- [ ] Language is clear and concise | 语言清晰简洁
|
||||
- [ ] No spelling or grammar errors | 无拼写或语法错误
|
||||
- [ ] Links are valid and working | 链接有效且可用
|
||||
- [ ] Code examples are tested and working | 代码示例已测试且可用
|
||||
- [ ] Formatting is consistent | 格式一致
|
||||
### Content Quality
|
||||
- [ ] Information is accurate and up-to-date
|
||||
- [ ] Language is clear and concise
|
||||
- [ ] No spelling or grammar errors
|
||||
- [ ] Links are valid and working
|
||||
- [ ] Code examples are tested and working
|
||||
- [ ] Formatting is consistent
|
||||
|
||||
### Documentation Standards | 文档标准
|
||||
- [ ] Follows project documentation style | 遵循项目文档风格
|
||||
- [ ] Includes necessary examples | 包含必要的示例
|
||||
- [ ] Technical terms are explained | 技术术语已解释
|
||||
- [ ] Self-review completed | 已完成自查
|
||||
### Documentation Standards
|
||||
- [ ] Follows project documentation style
|
||||
- [ ] Includes necessary examples
|
||||
- [ ] Technical terms are explained
|
||||
- [ ] Self-review completed
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
120
.github/PULL_REQUEST_TEMPLATE/frontend.md
vendored
@@ -1,119 +1,113 @@
|
||||
# Pull Request - Frontend | 前端 PR
|
||||
# Pull Request - Frontend
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots / Demo | 截图/演示
|
||||
## 📸 Screenshots / Demo
|
||||
|
||||
<!-- For UI changes, include before/after screenshots or video demo -->
|
||||
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
|
||||
|
||||
**Before | 变更前:**
|
||||
**Before:**
|
||||
|
||||
|
||||
**After | 变更后:**
|
||||
**After:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
### Test Environment | 测试环境
|
||||
- **OS | 操作系统:**
|
||||
- **Node Version | Node 版本:**
|
||||
- **Browser(s) | 浏览器:**
|
||||
### Test Environment
|
||||
- **OS:**
|
||||
- **Node Version:**
|
||||
- **Browser(s):**
|
||||
|
||||
### Manual Testing | 手动测试
|
||||
- [ ] Tested in development mode | 开发模式测试通过
|
||||
- [ ] Tested production build | 生产构建测试通过
|
||||
- [ ] Tested on multiple browsers | 多浏览器测试通过
|
||||
- [ ] Tested responsive design | 响应式设计测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
### Manual Testing
|
||||
- [ ] Tested in development mode
|
||||
- [ ] Tested production build
|
||||
- [ ] Tested on multiple browsers
|
||||
- [ ] Tested responsive design
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Internationalization | 国际化
|
||||
## 🌐 Internationalization
|
||||
|
||||
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
|
||||
- [ ] Both English and Chinese versions provided | 提供了中英文版本
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] All user-facing text supports i18n
|
||||
- [ ] Both English and Chinese versions provided
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
|
||||
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
|
||||
- [ ] No console errors or warnings | 无控制台错误或警告
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] Code builds successfully (`npm run build`)
|
||||
- [ ] Ran `npm run lint`
|
||||
- [ ] No console errors or warnings
|
||||
|
||||
### Testing | 测试
|
||||
- [ ] Component tests added/updated | 已添加/更新组件测试
|
||||
- [ ] Tests pass locally | 测试在本地通过
|
||||
### Testing
|
||||
- [ ] Component tests added/updated
|
||||
- [ ] Tests pass locally
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
|
||||
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Updated type definitions (TypeScript)
|
||||
- [ ] Added JSDoc comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
97
.github/PULL_REQUEST_TEMPLATE/general.md
vendored
@@ -1,98 +1,93 @@
|
||||
# Pull Request - General | 通用 PR
|
||||
# Pull Request - General
|
||||
|
||||
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
|
||||
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
> **💡 Tip:** Recommended PR title format `type(scope): description`
|
||||
> Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Description | 描述
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📝 Description
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Type of Change | 变更类型
|
||||
## 🎯 Type of Change
|
||||
|
||||
- [ ] 🐛 Bug fix | 修复 Bug
|
||||
- [ ] ✨ New feature | 新功能
|
||||
- [ ] 💥 Breaking change | 破坏性变更
|
||||
- [ ] 📝 Documentation update | 文档更新
|
||||
- [ ] 🎨 Code style update | 代码样式更新
|
||||
- [ ] ♻️ Refactoring | 重构
|
||||
- [ ] ⚡ Performance improvement | 性能优化
|
||||
- [ ] ✅ Test update | 测试更新
|
||||
- [ ] 🔧 Build/config change | 构建/配置变更
|
||||
- [ ] 🔒 Security fix | 安全修复
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📝 Documentation update
|
||||
- [ ] 🎨 Code style update
|
||||
- [ ] ♻️ Refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] ✅ Test update
|
||||
- [ ] 🔧 Build/config change
|
||||
- [ ] 🔒 Security fix
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Issues | 相关 Issue
|
||||
## 🔗 Related Issues
|
||||
|
||||
- Closes # | 关闭 #
|
||||
- Related to # | 相关 #
|
||||
- Closes #
|
||||
- Related to #
|
||||
|
||||
---
|
||||
|
||||
## 📋 Changes Made | 具体变更
|
||||
## 📋 Changes Made
|
||||
|
||||
**English:** | **中文:**
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing | 测试
|
||||
## 🧪 Testing
|
||||
|
||||
- [ ] Tested locally | 本地测试通过
|
||||
- [ ] Tests pass | 测试通过
|
||||
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
|
||||
- [ ] Tested locally
|
||||
- [ ] Tests pass
|
||||
- [ ] Verified no existing functionality broke
|
||||
|
||||
**Test details | 测试详情:**
|
||||
**Test details:**
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist | 检查清单
|
||||
## ✅ Checklist
|
||||
|
||||
### Code Quality | 代码质量
|
||||
- [ ] Code follows project style | 代码遵循项目风格
|
||||
- [ ] Self-review completed | 已完成代码自查
|
||||
- [ ] Comments added for complex logic | 已添加必要注释
|
||||
- [ ] No new warnings or errors | 无新的警告或错误
|
||||
### Code Quality
|
||||
- [ ] Code follows project style
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] No new warnings or errors
|
||||
|
||||
### Documentation | 文档
|
||||
- [ ] Updated relevant documentation | 已更新相关文档
|
||||
- [ ] Added inline comments where necessary | 已添加必要的代码注释
|
||||
### Documentation
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added inline comments where necessary
|
||||
|
||||
### Git
|
||||
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
|
||||
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
|
||||
- [ ] No merge conflicts | 无合并冲突
|
||||
- [ ] Commits follow conventional format
|
||||
- [ ] Rebased on latest `dev` branch
|
||||
- [ ] No merge conflicts
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security (if applicable) | 安全(如适用)
|
||||
## 🔒 Security (if applicable)
|
||||
|
||||
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
|
||||
- [ ] User inputs properly validated | 用户输入已正确验证
|
||||
- [ ] N/A | 不适用
|
||||
- [ ] No API keys or secrets hardcoded
|
||||
- [ ] User inputs properly validated
|
||||
- [ ] N/A
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Notes | 补充说明
|
||||
|
||||
**English:** | **中文:**
|
||||
## 📚 Additional Notes
|
||||
|
||||
|
||||
---
|
||||
|
||||
**By submitting this PR, I confirm | 提交此 PR,我确认:**
|
||||
**By submitting this PR, I confirm:**
|
||||
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
|
||||
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
|
||||
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
|
||||
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
|
||||
- [ ] My contribution is licensed under AGPL-3.0
|
||||
|
||||
---
|
||||
|
||||
🌟 **Thank you for your contribution! | 感谢你的贡献!**
|
||||
🌟 **Thank you for your contribution!**
|
||||
|
||||
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"]
|
||||
95
README.md
95
README.md
@@ -42,6 +42,12 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **Official Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### Official Links
|
||||
|
||||
- **Official Website**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Data Dashboard**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API Documentation**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Risk Warning**: This system is experimental. AI auto-trading carries significant risks. Strongly recommended for learning/research purposes or testing with small amounts only!
|
||||
|
||||
## Developer Community
|
||||
@@ -50,6 +56,50 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Before You Begin
|
||||
|
||||
To use NOFX, you'll need:
|
||||
|
||||
1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
|
||||
2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
|
||||
|
||||
---
|
||||
|
||||
## Supported Exchanges
|
||||
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Config Page
|
||||
@@ -87,44 +137,9 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Supported Exchanges
|
||||
|
||||
### CEX (Centralized Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Supported | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Supported | [Register](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Supported | [Register](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Supported | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Decentralized Perpetual Exchanges)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Supported AI Models
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Supported | [Get API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Supported | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Supported | [Get API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Supported | [Get API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Supported | [Get API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Supported | [Get API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Supported | [Get API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-Click Install (Recommended)
|
||||
### One-Click Install (Local/Server)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -133,6 +148,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
That's it! Open **http://127.0.0.1:3000** in your browser.
|
||||
|
||||
### One-Click Cloud Deploy (Railway)
|
||||
|
||||
Deploy to Railway with one click - no server setup required:
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
After deployment, Railway will provide a public URL to access your NOFX instance.
|
||||
|
||||
### Docker Compose (Manual)
|
||||
|
||||
```bash
|
||||
|
||||
177
api/server.go
177
api/server.go
@@ -157,6 +157,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
||||
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
||||
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
||||
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||||
|
||||
// AI model configuration
|
||||
protected.GET("/models", s.handleGetModelConfigs)
|
||||
@@ -202,6 +203,7 @@ func (s *Server) setupRoutes() {
|
||||
protected.GET("/trades", s.handleTrades)
|
||||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
|
||||
protected.GET("/decisions", s.handleDecisions)
|
||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||
protected.GET("/statistics", s.handleStatistics)
|
||||
@@ -1095,6 +1097,20 @@ func (s *Server) handleToggleCompetition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
||||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
||||
traderID := c.Param("id")
|
||||
|
||||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
||||
return
|
||||
}
|
||||
|
||||
riskInfo := autoTrader.GetGridRiskInfo()
|
||||
c.JSON(http.StatusOK, riskInfo)
|
||||
}
|
||||
|
||||
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
||||
func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -1368,7 +1384,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
if closeErr != nil {
|
||||
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
||||
SafeInternalError(c, "Failed to close position", closeErr)
|
||||
SafeInternalError(c, "Close position", closeErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1452,9 +1468,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
||||
FilledQuantity: quantity,
|
||||
AvgFillPrice: exitPrice,
|
||||
Commission: fee,
|
||||
FilledAt: time.Now(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
FilledAt: time.Now().UTC().UnixMilli(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
@@ -1482,7 +1498,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
@@ -1557,7 +1573,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: false,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||
@@ -1704,8 +1720,15 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each model's configuration
|
||||
// Update each model's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for modelID, modelData := range req.Models {
|
||||
// Find traders using this AI model BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||||
@@ -1713,6 +1736,12 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -1824,8 +1853,15 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
||||
}
|
||||
|
||||
// Update each exchange's configuration
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
@@ -1833,6 +1869,12 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||
for traderID := range tradersToReload {
|
||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||||
s.traderManager.RemoveTrader(traderID)
|
||||
}
|
||||
|
||||
// Reload all traders for this user to make new config take effect immediately
|
||||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
@@ -2294,28 +2336,14 @@ func (s *Server) handleOrders(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all orders for this trader
|
||||
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
|
||||
// Get orders with filters applied at database level
|
||||
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter by symbol and status if specified
|
||||
result := make([]interface{}, 0)
|
||||
for _, order := range allOrders {
|
||||
// Filter by symbol
|
||||
if symbol != "" && order.Symbol != symbol {
|
||||
continue
|
||||
}
|
||||
// Filter by status
|
||||
if statusFilter != "" && order.Status != statusFilter {
|
||||
continue
|
||||
}
|
||||
result = append(result, order)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
c.JSON(http.StatusOK, orders)
|
||||
}
|
||||
|
||||
// handleOrderFills Order fill details (all fills for a specific order)
|
||||
@@ -2355,6 +2383,40 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, fills)
|
||||
}
|
||||
|
||||
// handleOpenOrders Get open orders (pending SL/TP) from exchange
|
||||
func (s *Server) handleOpenOrders(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
// Get symbol parameter (required for exchange query)
|
||||
symbol := c.Query("symbol")
|
||||
if symbol == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol = market.Normalize(symbol)
|
||||
|
||||
// Get open orders from exchange
|
||||
openOrders, err := trader.GetOpenOrders(symbol)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get open orders", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, openOrders)
|
||||
}
|
||||
|
||||
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||||
func (s *Server) handleKlines(c *gin.Context) {
|
||||
// Get query parameters
|
||||
@@ -2968,7 +3030,44 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check max users limit
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
|
||||
existingUser, err := s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
// User exists, check OTP verification status
|
||||
if !existingUser.OTPVerified {
|
||||
// OTP not verified, verify password first for security
|
||||
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
// Password correct, allow user to continue OTP setup
|
||||
// Return existing OTP information
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": existingUser.ID,
|
||||
"email": existingUser.Email,
|
||||
"otp_secret": existingUser.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"message": "Incomplete registration detected, please continue OTP setup",
|
||||
})
|
||||
return
|
||||
}
|
||||
// OTP already verified, reject duplicate registration
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check max users limit (only for new users)
|
||||
maxUsers := config.Get().MaxUsers
|
||||
if maxUsers > 0 {
|
||||
userCount, err := s.store.User().Count()
|
||||
@@ -2982,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 {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
_, err := s.store.User().GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate password hash
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
@@ -3120,10 +3202,15 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
|
||||
// Check if OTP is verified
|
||||
if !user.OTPVerified {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "Account has not completed OTP setup",
|
||||
// Return OTP info so user can complete setup
|
||||
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": user.ID,
|
||||
"email": user.Email,
|
||||
"otp_secret": user.OTPSecret,
|
||||
"qr_code_url": qrCodeURL,
|
||||
"requires_otp_setup": true,
|
||||
"message": "Please complete OTP setup first",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,10 +122,10 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
|
||||
}
|
||||
|
||||
execPrice := applySlippage(price, acc.slippageRate, side, false)
|
||||
notional := execPrice * quantity
|
||||
closingFee := notional * acc.feeRate
|
||||
closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
|
||||
closingFee := closeNotional * acc.feeRate
|
||||
|
||||
// Calculate proportional opening fee for the quantity being closed
|
||||
// Calculate proportional values based on the portion being closed
|
||||
closePortion := quantity / pos.Quantity
|
||||
openingFeePortion := pos.AccumulatedFee * closePortion
|
||||
totalFee := closingFee + openingFeePortion
|
||||
@@ -133,13 +133,17 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
|
||||
realized := realizedPnL(pos, quantity, execPrice)
|
||||
|
||||
marginPortion := pos.Margin * closePortion
|
||||
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
|
||||
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
|
||||
entryNotionalPortion := pos.Notional * closePortion
|
||||
|
||||
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
|
||||
acc.cash += marginPortion + realized - closingFee
|
||||
// But for realized P&L tracking, we include both fees
|
||||
acc.realizedPnL += realized - totalFee
|
||||
|
||||
pos.Quantity -= quantity
|
||||
pos.Notional -= notional
|
||||
pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
|
||||
pos.Margin -= marginPortion
|
||||
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee
|
||||
|
||||
|
||||
@@ -124,11 +124,23 @@ func (df *DataFeed) DecisionBarCount() int {
|
||||
}
|
||||
|
||||
func (df *DataFeed) DecisionTimestamp(index int) int64 {
|
||||
// Bounds check to prevent panic
|
||||
if index < 0 || index >= len(df.decisionTimes) {
|
||||
return 0
|
||||
}
|
||||
return df.decisionTimes[index]
|
||||
}
|
||||
|
||||
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
|
||||
series := df.symbolSeries[symbol].byTF[tf]
|
||||
// Nil checks to prevent panic
|
||||
ss, ok := df.symbolSeries[symbol]
|
||||
if !ok || ss == nil {
|
||||
return nil
|
||||
}
|
||||
series, ok := ss.byTF[tf]
|
||||
if !ok || series == nil {
|
||||
return nil
|
||||
}
|
||||
idx := sort.Search(len(series.closeTimes), func(i int) bool {
|
||||
return series.closeTimes[i] > ts
|
||||
})
|
||||
|
||||
@@ -91,8 +91,13 @@ func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
|
||||
return maxDD
|
||||
}
|
||||
|
||||
// sharpeRatio calculates the Sharpe ratio from equity points.
|
||||
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
|
||||
// Returns math.NaN() for edge cases (insufficient data, zero variance).
|
||||
func sharpeRatio(points []EquityPoint) float64 {
|
||||
if len(points) < 2 {
|
||||
// Need at least 10 data points for meaningful Sharpe calculation
|
||||
const minDataPoints = 10
|
||||
if len(points) < minDataPoints {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -108,34 +113,42 @@ func sharpeRatio(points []EquityPoint) float64 {
|
||||
returns = append(returns, ret)
|
||||
prev = curr
|
||||
}
|
||||
if len(returns) == 0 {
|
||||
if len(returns) < minDataPoints-1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Calculate mean return
|
||||
mean := 0.0
|
||||
for _, r := range returns {
|
||||
mean += r
|
||||
}
|
||||
mean /= float64(len(returns))
|
||||
|
||||
// Calculate sample variance (using n-1 for unbiased estimator)
|
||||
variance := 0.0
|
||||
for _, r := range returns {
|
||||
diff := r - mean
|
||||
variance += diff * diff
|
||||
}
|
||||
variance /= float64(len(returns))
|
||||
if len(returns) > 1 {
|
||||
variance /= float64(len(returns) - 1)
|
||||
}
|
||||
|
||||
std := math.Sqrt(variance)
|
||||
if std == 0 {
|
||||
if mean > 0 {
|
||||
return 999
|
||||
}
|
||||
if mean < 0 {
|
||||
return -999
|
||||
}
|
||||
if std < 1e-10 {
|
||||
// Zero or near-zero volatility - return 0 instead of infinity/NaN
|
||||
return 0
|
||||
}
|
||||
return mean / std
|
||||
|
||||
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
|
||||
// Annualize by multiplying by sqrt(periods per year)
|
||||
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
|
||||
// For conservative estimate, use sqrt(252) as if daily returns
|
||||
periodsPerYear := 252.0
|
||||
annualizationFactor := math.Sqrt(periodsPerYear)
|
||||
|
||||
sharpe := (mean / std) * annualizationFactor
|
||||
return sharpe
|
||||
}
|
||||
|
||||
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
@@ -189,7 +202,8 @@ func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
|
||||
if totalLossAmount > 0 {
|
||||
metrics.ProfitFactor = totalWinAmount / totalLossAmount
|
||||
} else if totalWinAmount > 0 {
|
||||
metrics.ProfitFactor = 999
|
||||
// No losses but have wins - use a high but reasonable cap
|
||||
metrics.ProfitFactor = 100.0
|
||||
}
|
||||
|
||||
bestSymbol := ""
|
||||
|
||||
@@ -2,15 +2,39 @@ package backtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var persistenceDB *sql.DB
|
||||
var dbIsPostgres bool
|
||||
|
||||
// UseDatabase enables database-backed persistence for all backtest storage operations.
|
||||
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
|
||||
func UseDatabase(db *sql.DB) {
|
||||
persistenceDB = db
|
||||
}
|
||||
|
||||
// UseDatabaseWithType enables database-backed persistence with explicit type.
|
||||
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
|
||||
persistenceDB = db
|
||||
dbIsPostgres = isPostgres
|
||||
}
|
||||
|
||||
func usingDB() bool {
|
||||
return persistenceDB != nil
|
||||
}
|
||||
|
||||
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
|
||||
func convertQuery(query string) string {
|
||||
if !dbIsPostgres {
|
||||
return query
|
||||
}
|
||||
result := query
|
||||
index := 1
|
||||
for strings.Contains(result, "?") {
|
||||
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
|
||||
index++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -73,12 +73,12 @@ func enforceRetentionDB(maxRuns int) {
|
||||
RunStateFailed,
|
||||
RunStateLiquidated,
|
||||
}
|
||||
query := `
|
||||
query := convertQuery(`
|
||||
SELECT run_id FROM backtest_runs
|
||||
WHERE state IN (?, ?, ?, ?)
|
||||
ORDER BY updated_at DESC
|
||||
OFFSET ?
|
||||
`
|
||||
`)
|
||||
rows, err := persistenceDB.Query(query,
|
||||
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,8 +60,9 @@ type Runner struct {
|
||||
aiCache *AICache
|
||||
cachePath string
|
||||
|
||||
lockInfo *RunLockInfo
|
||||
lockStop chan struct{}
|
||||
lockInfo *RunLockInfo
|
||||
lockStop chan struct{}
|
||||
lockStopOnce sync.Once // Ensures lockStop is closed only once
|
||||
}
|
||||
|
||||
// NewRunner constructs a backtest runner.
|
||||
@@ -175,10 +176,12 @@ func (r *Runner) lockHeartbeatLoop() {
|
||||
}
|
||||
|
||||
func (r *Runner) releaseLock() {
|
||||
if r.lockStop != nil {
|
||||
close(r.lockStop)
|
||||
r.lockStop = nil
|
||||
}
|
||||
// Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
|
||||
r.lockStopOnce.Do(func() {
|
||||
if r.lockStop != nil {
|
||||
close(r.lockStop)
|
||||
}
|
||||
})
|
||||
if err := deleteRunLock(r.cfg.RunID); err != nil {
|
||||
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
|
||||
}
|
||||
@@ -297,9 +300,12 @@ func (r *Runner) stepOnce() error {
|
||||
if shouldDecide {
|
||||
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
|
||||
if err != nil {
|
||||
rec.Success = false
|
||||
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
||||
_ = r.logDecision(rec)
|
||||
// Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
|
||||
if rec != nil {
|
||||
rec.Success = false
|
||||
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
|
||||
_ = r.logDecision(rec)
|
||||
}
|
||||
return err
|
||||
}
|
||||
record = rec
|
||||
@@ -617,6 +623,10 @@ func (r *Runner) invokeAIWithRetry(ctx *kernel.Context) (*kernel.FullDecision, e
|
||||
|
||||
func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
|
||||
symbol := dec.Symbol
|
||||
if symbol == "" {
|
||||
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
|
||||
}
|
||||
|
||||
usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
|
||||
actionRecord := store.DecisionAction{
|
||||
Action: dec.Action,
|
||||
@@ -625,9 +635,13 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6
|
||||
Timestamp: time.UnixMilli(ts).UTC(),
|
||||
}
|
||||
|
||||
basePrice := priceMap[symbol]
|
||||
if basePrice <= 0 {
|
||||
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s", symbol)
|
||||
if priceMap == nil {
|
||||
return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
|
||||
}
|
||||
|
||||
basePrice, ok := priceMap[symbol]
|
||||
if !ok || basePrice <= 0 {
|
||||
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
|
||||
}
|
||||
fillPrice := r.executionPrice(symbol, basePrice, ts)
|
||||
|
||||
@@ -757,6 +771,9 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6
|
||||
}
|
||||
}
|
||||
|
||||
// MinPositionSizeUSD is the minimum position size in USD to avoid dust positions
|
||||
const MinPositionSizeUSD = 10.0
|
||||
|
||||
func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
|
||||
snapshot := r.snapshotState()
|
||||
equity := snapshot.Equity
|
||||
@@ -788,6 +805,13 @@ func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
|
||||
sizeUSD = maxPositionValue
|
||||
}
|
||||
|
||||
// Reject positions below minimum size to avoid dust positions
|
||||
if sizeUSD < MinPositionSizeUSD {
|
||||
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
|
||||
sizeUSD, MinPositionSizeUSD)
|
||||
return 0
|
||||
}
|
||||
|
||||
qty := sizeUSD / price
|
||||
if qty < 0 {
|
||||
qty = 0
|
||||
@@ -805,20 +829,37 @@ func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision
|
||||
}
|
||||
|
||||
func (r *Runner) resolveLeverage(requested int, symbol string) int {
|
||||
if requested > 0 {
|
||||
return requested
|
||||
}
|
||||
sym := strings.ToUpper(symbol)
|
||||
if sym == "BTCUSDT" || sym == "ETHUSDT" {
|
||||
if r.cfg.Leverage.BTCETHLeverage > 0 {
|
||||
return r.cfg.Leverage.BTCETHLeverage
|
||||
isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
|
||||
|
||||
// Determine configured max leverage for this symbol type
|
||||
var maxLeverage int
|
||||
if isBTCETH {
|
||||
maxLeverage = r.cfg.Leverage.BTCETHLeverage
|
||||
if maxLeverage <= 0 {
|
||||
maxLeverage = 10 // Default max for BTC/ETH
|
||||
}
|
||||
} else {
|
||||
if r.cfg.Leverage.AltcoinLeverage > 0 {
|
||||
return r.cfg.Leverage.AltcoinLeverage
|
||||
maxLeverage = r.cfg.Leverage.AltcoinLeverage
|
||||
if maxLeverage <= 0 {
|
||||
maxLeverage = 5 // Default max for altcoins
|
||||
}
|
||||
}
|
||||
return 5
|
||||
|
||||
// Use requested leverage if provided, otherwise use max as default
|
||||
leverage := requested
|
||||
if leverage <= 0 {
|
||||
leverage = maxLeverage
|
||||
}
|
||||
|
||||
// Enforce max leverage limit
|
||||
if leverage > maxLeverage {
|
||||
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
|
||||
leverage, maxLeverage, symbol)
|
||||
leverage = maxLeverage
|
||||
}
|
||||
|
||||
return leverage
|
||||
}
|
||||
|
||||
func (r *Runner) remainingPosition(symbol, side string) float64 {
|
||||
@@ -854,6 +895,12 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
|
||||
list := make([]kernel.PositionInfo, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
price := priceMap[pos.Symbol]
|
||||
pnl := unrealizedPnL(pos, price)
|
||||
// Calculate P&L percentage based on entry notional (position cost)
|
||||
pnlPct := 0.0
|
||||
if pos.Notional > 0 {
|
||||
pnlPct = (pnl / pos.Notional) * 100
|
||||
}
|
||||
list = append(list, kernel.PositionInfo{
|
||||
Symbol: pos.Symbol,
|
||||
Side: pos.Side,
|
||||
@@ -861,8 +908,8 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
|
||||
MarkPrice: price,
|
||||
Quantity: pos.Quantity,
|
||||
Leverage: pos.Leverage,
|
||||
UnrealizedPnL: unrealizedPnL(pos, price),
|
||||
UnrealizedPnLPct: 0,
|
||||
UnrealizedPnL: pnl,
|
||||
UnrealizedPnLPct: pnlPct,
|
||||
LiquidationPrice: pos.LiquidationPrice,
|
||||
MarginUsed: pos.Margin,
|
||||
UpdateTime: time.Now().UnixMilli(),
|
||||
|
||||
@@ -17,17 +17,17 @@ func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
`), runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCheckpointDB(runID string) (*Checkpoint, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, os.ErrNotExist
|
||||
@@ -57,25 +57,25 @@ func saveConfigDB(runID string, cfg *BacktestConfig) error {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
`), runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE run_id = ?
|
||||
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
`), userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadConfigDB(runID string) (*BacktestConfig, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT config_json FROM backtest_runs WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,18 +96,18 @@ func saveRunMetadataDB(meta *RunMetadata) error {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
if _, err := persistenceDB.Exec(`
|
||||
if _, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO NOTHING
|
||||
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
`), meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
`), userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -128,10 +128,10 @@ func loadRunMetadataDB(runID string) (*RunMetadata, error) {
|
||||
createdISO string
|
||||
updatedISO string
|
||||
)
|
||||
err := persistenceDB.QueryRow(`
|
||||
err := persistenceDB.QueryRow(convertQuery(`
|
||||
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
|
||||
FROM backtest_runs WHERE run_id = ?
|
||||
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
`), runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -183,18 +183,18 @@ func loadRunIDsDB() ([]string, error) {
|
||||
}
|
||||
|
||||
func appendEquityPointDB(runID string, point EquityPoint) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
`), runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
|
||||
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -211,18 +211,18 @@ func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
|
||||
}
|
||||
|
||||
func appendTradeEventDB(runID string, event TradeEvent) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
`), runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
|
||||
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,17 +243,17 @@ func saveMetricsDB(runID string, metrics *Metrics) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_metrics (run_id, payload, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
|
||||
`, runID, data)
|
||||
`), runID, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
var payload []byte
|
||||
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload)
|
||||
err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_metrics WHERE run_id = ?`), runID).Scan(&payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,22 +265,21 @@ func loadMetricsDB(runID string) (*Metrics, error) {
|
||||
}
|
||||
|
||||
func saveProgressDB(runID string, payload progressPayload) error {
|
||||
_, err := persistenceDB.Exec(`
|
||||
_, err := persistenceDB.Exec(convertQuery(`
|
||||
UPDATE backtest_runs
|
||||
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
|
||||
WHERE run_id = ?
|
||||
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
`), payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
|
||||
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if cycle > 0 {
|
||||
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY created_at DESC LIMIT 1`, runID, cycle)
|
||||
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
|
||||
} else {
|
||||
rows, err = persistenceDB.Query(query+` ORDER BY created_at DESC LIMIT 1`, runID)
|
||||
rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY created_at DESC LIMIT 1`), runID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -308,20 +307,20 @@ func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = persistenceDB.Exec(`
|
||||
_, err = persistenceDB.Exec(convertQuery(`
|
||||
INSERT INTO backtest_decisions (run_id, cycle, payload)
|
||||
VALUES (?, ?, ?)
|
||||
`, runID, record.CycleNumber, data)
|
||||
`), runID, record.CycleNumber, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT payload FROM backtest_decisions
|
||||
WHERE run_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`, runID, limit, offset)
|
||||
`), runID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -428,10 +427,10 @@ func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
|
||||
}
|
||||
|
||||
func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
|
||||
rows, err := persistenceDB.Query(`
|
||||
rows, err := persistenceDB.Query(convertQuery(`
|
||||
SELECT id, cycle, payload FROM backtest_decisions
|
||||
WHERE run_id = ? ORDER BY id ASC
|
||||
`, runID)
|
||||
`), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -494,6 +493,6 @@ func listIndexEntriesDB() ([]RunIndexEntry, error) {
|
||||
}
|
||||
|
||||
func deleteRunDB(runID string) error {
|
||||
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID)
|
||||
_, err := persistenceDB.Exec(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
233
cmd/lighter_test/main.go
Normal file
233
cmd/lighter_test/main.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Lighter API Authentication Test Tool
|
||||
// Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=... [-testnet]
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
walletAddr := flag.String("wallet", "", "Ethereum wallet address")
|
||||
apiKeyPrivateKey := flag.String("apikey", "", "API key private key (40 bytes hex)")
|
||||
apiKeyIndex := flag.Int("apikeyindex", 0, "API key index (0-255)")
|
||||
testnet := flag.Bool("testnet", false, "Use testnet instead of mainnet")
|
||||
flag.Parse()
|
||||
|
||||
if *walletAddr == "" || *apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: go run cmd/lighter_test/main.go -wallet=0x... -apikey=...")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" -wallet Ethereum wallet address (required)")
|
||||
fmt.Println(" -apikey API key private key, 40 bytes hex (required)")
|
||||
fmt.Println(" -apikeyindex API key index, 0-255 (default: 0)")
|
||||
fmt.Println(" -testnet Use testnet instead of mainnet")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Authentication Test ===")
|
||||
fmt.Printf("Wallet: %s\n", *walletAddr)
|
||||
fmt.Printf("API Key Index: %d\n", *apiKeyIndex)
|
||||
fmt.Printf("Testnet: %v\n", *testnet)
|
||||
fmt.Println()
|
||||
|
||||
// Determine base URL
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
if *testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(300)
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info
|
||||
fmt.Println("Step 1: Getting account info...")
|
||||
accountInfo, err := getAccountByL1Address(client, baseURL, *walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to get account info: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Account index = %d\n\n", accountInfo.AccountIndex)
|
||||
|
||||
// Step 2: Create TxClient
|
||||
fmt.Println("Step 2: Creating TxClient...")
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
*apiKeyPrivateKey,
|
||||
accountInfo.AccountIndex,
|
||||
uint8(*apiKeyIndex),
|
||||
chainID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create TxClient: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("SUCCESS: TxClient created\n")
|
||||
|
||||
// Step 3: Generate auth token
|
||||
fmt.Println("Step 3: Generating auth token...")
|
||||
deadline := time.Now().Add(1 * time.Hour)
|
||||
authToken, err := txClient.GetAuthToken(deadline)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to generate auth token: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("SUCCESS: Auth token generated\n")
|
||||
fmt.Printf("Token: %s...\n", authToken[:min(50, len(authToken))])
|
||||
fmt.Printf("Valid until: %s\n\n", deadline.Format(time.RFC3339))
|
||||
|
||||
// Step 4: Test GetActiveOrders API with auth query parameter
|
||||
fmt.Println("Step 4: Testing GetActiveOrders API...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
fmt.Printf("Endpoint: %s...\n", endpoint[:min(120, len(endpoint))])
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to create request: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Request failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("Response: %s\n\n", string(body))
|
||||
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("ERROR: Failed to parse response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
fmt.Printf("API ERROR: code=%d, message=%s\n", apiResp.Code, apiResp.Message)
|
||||
fmt.Println("\n=== DIAGNOSTIC INFO ===")
|
||||
fmt.Println("If you see 'invalid signature', possible causes:")
|
||||
fmt.Println("1. API key is not registered on-chain")
|
||||
fmt.Println("2. API key private key is incorrect")
|
||||
fmt.Println("3. API key index is wrong")
|
||||
fmt.Println("4. Account index mismatch")
|
||||
fmt.Println("\nTo fix:")
|
||||
fmt.Println("- Go to app.lighter.xyz and register/verify your API key")
|
||||
fmt.Println("- Make sure you're using the correct API key private key")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("SUCCESS: Retrieved %d orders\n", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
if i >= 5 {
|
||||
fmt.Printf("... and %d more orders\n", len(apiResp.Orders)-5)
|
||||
break
|
||||
}
|
||||
fmt.Printf(" Order %s: %s %s @ %s\n", order.OrderID, order.Side, order.Type, order.Price)
|
||||
}
|
||||
|
||||
// Step 5: Test GetTrades API (also needs auth)
|
||||
fmt.Println("\nStep 5: Testing GetTrades API...")
|
||||
tradesEndpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=5&auth=%s",
|
||||
baseURL, accountInfo.AccountIndex, encodedAuth)
|
||||
|
||||
tradesReq, _ := http.NewRequest("GET", tradesEndpoint, nil)
|
||||
tradesResp, err := client.Do(tradesReq)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Trades request failed: %v\n", err)
|
||||
} else {
|
||||
defer tradesResp.Body.Close()
|
||||
tradesBody, _ := io.ReadAll(tradesResp.Body)
|
||||
fmt.Printf("Status: %d\n", tradesResp.StatusCode)
|
||||
if tradesResp.StatusCode == 200 {
|
||||
fmt.Println("SUCCESS: GetTrades API working")
|
||||
} else {
|
||||
fmt.Printf("Response: %s\n", string(tradesBody))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== ALL TESTS PASSED ===")
|
||||
}
|
||||
|
||||
// AccountInfo represents Lighter account information
|
||||
type AccountInfo struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
L1Address string `json:"l1_address"`
|
||||
}
|
||||
|
||||
// getAccountByL1Address gets account info by L1 wallet address
|
||||
func getAccountByL1Address(client *http.Client, baseURL, walletAddr string) (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response - can be in "accounts" or "sub_accounts" field
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
SubAccounts []AccountInfo `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// Check main accounts first
|
||||
if len(apiResp.Accounts) > 0 {
|
||||
return &apiResp.Accounts[0], nil
|
||||
}
|
||||
|
||||
// Check sub-accounts
|
||||
if len(apiResp.SubAccounts) > 0 {
|
||||
return &apiResp.SubAccounts[0], nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no account found for address: %s", walletAddr)
|
||||
}
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Web ベース設定**: JSON 編集不要 - Web インターフェースですべて設定
|
||||
- **リアルタイムダッシュボード**: ライブポジション、損益追跡、思考連鎖付き AI 決定ログ
|
||||
|
||||
### 公式リンク
|
||||
|
||||
- **公式サイト**: [https://nofxai.com](https://nofxai.com)
|
||||
- **データダッシュボード**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API ドキュメント**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **リスク警告**: このシステムは実験的です。AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを強くお勧めします!
|
||||
|
||||
## 開発者コミュニティ
|
||||
@@ -30,6 +36,50 @@ Telegram 開発者コミュニティに参加: **[NOFX 開発者コミュニテ
|
||||
|
||||
---
|
||||
|
||||
## 始める前に
|
||||
|
||||
NOFXを使用するには以下が必要です:
|
||||
|
||||
1. **取引所アカウント** - サポートされている取引所に登録し、取引権限付きのAPI認証情報を作成
|
||||
2. **AI モデル API キー** - サポートされているプロバイダーから取得(コスト効率の良いDeepSeekを推奨)
|
||||
|
||||
---
|
||||
|
||||
## サポート取引所
|
||||
|
||||
### CEX (中央集権型取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ サポート | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ サポート | [登録](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ サポート | [登録](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ サポート | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (分散型永久先物取引所)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ サポート | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ サポート | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ サポート | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## サポート AI モデル
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ サポート | [API キー取得](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ サポート | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ サポート | [API キー取得](https://platform.openai.com) |
|
||||
| **Claude** | ✅ サポート | [API キー取得](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ サポート | [API キー取得](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ サポート | [API キー取得](https://console.x.ai) |
|
||||
| **Kimi** | ✅ サポート | [API キー取得](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### オプション 1: Docker デプロイ(推奨)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **웹 기반 설정**: JSON 편집 불필요 - 웹 인터페이스에서 모든 설정 완료
|
||||
- **실시간 대시보드**: 실시간 포지션, 손익 추적, 사고의 연쇄가 포함된 AI 결정 로그
|
||||
|
||||
### 공식 링크
|
||||
|
||||
- **공식 웹사이트**: [https://nofxai.com](https://nofxai.com)
|
||||
- **데이터 대시보드**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 문서**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **위험 경고**: 이 시스템은 실험적입니다. AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 목적 또는 소액 테스트만 강력히 권장합니다!
|
||||
|
||||
## 개발자 커뮤니티
|
||||
@@ -30,6 +36,50 @@ Telegram 개발자 커뮤니티 참여: **[NOFX 개발자 커뮤니티](https://
|
||||
|
||||
---
|
||||
|
||||
## 시작하기 전에
|
||||
|
||||
NOFX를 사용하려면 다음이 필요합니다:
|
||||
|
||||
1. **거래소 계정** - 지원되는 거래소에 등록하고 거래 권한이 있는 API 자격 증명 생성
|
||||
2. **AI 모델 API 키** - 지원되는 제공업체에서 획득 (비용 효율성을 위해 DeepSeek 권장)
|
||||
|
||||
---
|
||||
|
||||
## 지원 거래소
|
||||
|
||||
### CEX (중앙화 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ 지원 | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ 지원 | [등록](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ 지원 | [등록](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ 지원 | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (탈중앙화 영구 선물 거래소)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ 지원 | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ 지원 | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ 지원 | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## 지원 AI 모델
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ 지원 | [API 키 받기](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ 지원 | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ 지원 | [API 키 받기](https://platform.openai.com) |
|
||||
| **Claude** | ✅ 지원 | [API 키 받기](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ 지원 | [API 키 받기](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ 지원 | [API 키 받기](https://console.x.ai) |
|
||||
| **Kimi** | ✅ 지원 | [API 키 받기](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 옵션 1: Docker 배포 (권장)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Веб-конфигурация**: Без редактирования JSON — настройка всего через веб-интерфейс
|
||||
- **Панель реального времени**: Живые позиции, отслеживание P/L, логи решений AI с цепочкой рассуждений
|
||||
|
||||
### Официальные ссылки
|
||||
|
||||
- **Официальный сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель данных**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документация API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Предупреждение о рисках**: Эта система экспериментальная. AI автоторговля несёт значительные риски. Настоятельно рекомендуется использовать только для обучения/исследований или тестирования с небольшими суммами!
|
||||
|
||||
## Сообщество разработчиков
|
||||
@@ -30,6 +36,50 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед началом
|
||||
|
||||
Для использования NOFX вам понадобится:
|
||||
|
||||
1. **Аккаунт биржи** - Зарегистрируйтесь на поддерживаемой бирже и создайте API ключи с правами торговли
|
||||
2. **API ключ AI модели** - Получите от любого поддерживаемого провайдера (рекомендуется DeepSeek для экономии)
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые биржи
|
||||
|
||||
### CEX (Централизованные биржи)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Поддерживается | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Поддерживается | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Поддерживается | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Поддерживается | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Децентрализованные биржи)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Поддерживается | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Поддерживается | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Поддерживается | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые AI модели
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Поддерживается | [Получить](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Поддерживается | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Поддерживается | [Получить](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Поддерживается | [Получить](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Поддерживается | [Получить](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Поддерживается | [Получить](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Поддерживается | [Получить](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Вариант 1: Docker развёртывание (рекомендуется)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Веб-конфігурація**: Без редагування JSON — налаштування всього через веб-інтерфейс
|
||||
- **Панель реального часу**: Живі позиції, відстеження P/L, логи рішень AI з ланцюжком міркувань
|
||||
|
||||
### Офіційні посилання
|
||||
|
||||
- **Офіційний сайт**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Панель даних**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Документація API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Попередження про ризики**: Ця система експериментальна. AI автоторгівля несе значні ризики. Наполегливо рекомендується використовувати лише для навчання/досліджень або тестування з невеликими сумами!
|
||||
|
||||
## Спільнота розробників
|
||||
@@ -30,6 +36,50 @@
|
||||
|
||||
---
|
||||
|
||||
## Перед початком
|
||||
|
||||
Для використання NOFX вам знадобиться:
|
||||
|
||||
1. **Акаунт біржі** - Зареєструйтесь на підтримуваній біржі та створіть API ключі з правами торгівлі
|
||||
2. **API ключ AI моделі** - Отримайте від будь-якого підтримуваного провайдера (рекомендується DeepSeek для економії)
|
||||
|
||||
---
|
||||
|
||||
## Підтримувані біржі
|
||||
|
||||
### CEX (Централізовані біржі)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Підтримується | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Підтримується | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Підтримується | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Підтримується | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Децентралізовані біржі)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Підтримується | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Підтримується | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Підтримується | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Підтримувані AI моделі
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Підтримується | [Отримати](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Підтримується | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Підтримується | [Отримати](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Підтримується | [Отримати](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Підтримується | [Отримати](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Підтримується | [Отримати](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Підтримується | [Отримати](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Швидкий старт
|
||||
|
||||
### Варіант 1: Docker розгортання (рекомендовано)
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- **Cấu Hình Web**: Không cần chỉnh sửa JSON - cấu hình mọi thứ qua giao diện web
|
||||
- **Dashboard Thời Gian Thực**: Vị thế trực tiếp, theo dõi P/L, nhật ký quyết định AI với chuỗi suy luận
|
||||
|
||||
### Liên Kết Chính Thức
|
||||
|
||||
- **Website Chính Thức**: [https://nofxai.com](https://nofxai.com)
|
||||
- **Bảng Điều Khiển Dữ Liệu**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **Tài Liệu API**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **Cảnh Báo Rủi Ro**: Hệ thống này mang tính thử nghiệm. Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc kiểm tra với số tiền nhỏ!
|
||||
|
||||
## Cộng Đồng Nhà Phát Triển
|
||||
@@ -30,6 +36,50 @@ Tham gia cộng đồng Telegram: **[NOFX Developer Community](https://t.me/nofx
|
||||
|
||||
---
|
||||
|
||||
## Trước Khi Bắt Đầu
|
||||
|
||||
Để sử dụng NOFX, bạn cần:
|
||||
|
||||
1. **Tài khoản sàn giao dịch** - Đăng ký trên sàn được hỗ trợ và tạo API key với quyền giao dịch
|
||||
2. **API Key mô hình AI** - Lấy từ nhà cung cấp được hỗ trợ (khuyến nghị DeepSeek để tiết kiệm chi phí)
|
||||
|
||||
---
|
||||
|
||||
## Sàn Giao Dịch Được Hỗ Trợ
|
||||
|
||||
### CEX (Sàn Tập Trung)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Binance** | ✅ Hỗ trợ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| **Bybit** | ✅ Hỗ trợ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| **OKX** | ✅ Hỗ trợ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| **Bitget** | ✅ Hỗ trợ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
|
||||
### Perp-DEX (Sàn Phi Tập Trung)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Hỗ trợ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Hỗ trợ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Hỗ trợ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## Mô Hình AI Được Hỗ Trợ
|
||||
|
||||
| Mô hình AI | Trạng thái | Lấy API Key |
|
||||
|----------|--------|-------------|
|
||||
| **DeepSeek** | ✅ Hỗ trợ | [Lấy API Key](https://platform.deepseek.com) |
|
||||
| **Qwen** | ✅ Hỗ trợ | [Lấy API Key](https://dashscope.console.aliyun.com) |
|
||||
| **OpenAI (GPT)** | ✅ Hỗ trợ | [Lấy API Key](https://platform.openai.com) |
|
||||
| **Claude** | ✅ Hỗ trợ | [Lấy API Key](https://console.anthropic.com) |
|
||||
| **Gemini** | ✅ Hỗ trợ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| **Grok** | ✅ Hỗ trợ | [Lấy API Key](https://console.x.ai) |
|
||||
| **Kimi** | ✅ Hỗ trợ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
|
||||
---
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### Tùy chọn 1: Triển khai Docker (Khuyến nghị)
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
- **Tinkle** - [@Web3Tinkle](https://x.com/Web3Tinkle)
|
||||
- **官方 Twitter** - [@nofx_official](https://x.com/nofx_official)
|
||||
|
||||
### 官方链接
|
||||
|
||||
- **官网**: [https://nofxai.com](https://nofxai.com)
|
||||
- **数据站点**: [https://nofxos.ai/dashboard](https://nofxos.ai/dashboard)
|
||||
- **API 文档**: [https://nofxos.ai/api-docs](https://nofxos.ai/api-docs)
|
||||
|
||||
> **风险提示**: 本系统为实验性质。AI 自动交易存在重大风险。强烈建议仅用于学习/研究目的或小额测试!
|
||||
|
||||
## 开发者社区
|
||||
@@ -42,19 +48,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
## 开始之前
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

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

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

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

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

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

|
||||
*多数据源策略配置与 AI 测试*
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 (推荐)
|
||||
### 一键安装 (本地/服务器)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -103,6 +119,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
完成!打开浏览器访问 **http://127.0.0.1:3000**
|
||||
|
||||
### 一键云部署 (Railway)
|
||||
|
||||
一键部署到 Railway - 无需自己搭建服务器:
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
部署后,Railway 会提供一个公网 URL 访问你的 NOFX 实例。
|
||||
|
||||
### Docker Compose (手动)
|
||||
|
||||
```bash
|
||||
|
||||
281
docs/market-regime-classification-en.md
Normal file
281
docs/market-regime-classification-en.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Market Regime Classification Framework
|
||||
|
||||
> A comprehensive market state identification system for quantitative trading strategy matching
|
||||
|
||||
---
|
||||
|
||||
## 1. Classification Dimensions Overview
|
||||
|
||||
Market state identification requires analysis across multiple dimensions:
|
||||
|
||||
| Dimension | Sub-dimensions | Description |
|
||||
|-----------|---------------|-------------|
|
||||
| **Trend** | Direction, Strength | Determine market movement direction and momentum |
|
||||
| **Volatility** | Amplitude, Frequency | Measure price fluctuation characteristics |
|
||||
| **Structure** | Pattern, Phase | Identify market structure and cycle position |
|
||||
|
||||
---
|
||||
|
||||
## 2. Primary Classification (5 Categories)
|
||||
|
||||
### 2.1 Classification Overview
|
||||
|
||||
| Code | Name | Key Characteristics | Suitable Strategies |
|
||||
|------|------|---------------------|---------------------|
|
||||
| `TREND_UP` | Uptrend | Higher highs & higher lows | Trend following, Breakout |
|
||||
| `TREND_DOWN` | Downtrend | Lower highs & lower lows | Trend following, Short selling |
|
||||
| `RANGE` | Range-bound | Price oscillates within bounds | Grid trading, Mean reversion |
|
||||
| `TRANSITION` | Transition | Uncertain directional period | Wait & watch, Small positions |
|
||||
| `BREAKOUT` | Breakout | Price breaks key levels | Breakout trading |
|
||||
|
||||
### 2.2 Identification Indicators
|
||||
|
||||
- **ADX (Average Directional Index)**: Measures trend strength
|
||||
- ADX > 25: Clear trend exists
|
||||
- ADX < 20: Range-bound market
|
||||
- **EMA Alignment**: Determines trend direction
|
||||
- EMA20 > EMA50 > EMA200: Bullish alignment
|
||||
- EMA20 < EMA50 < EMA200: Bearish alignment
|
||||
|
||||
---
|
||||
|
||||
## 3. Secondary Classification (18 Sub-categories)
|
||||
|
||||
### 3.1 Uptrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TU_STRONG_LOW_VOL` | Strong Uptrend · Low Vol | Steady rise, shallow pullbacks | ADX>40, ATR%<2%, Pullback<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | Strong Uptrend · High Vol | Rapid surge, high volatility | ADX>40, ATR%>4%, MACD histogram expanding |
|
||||
| `TU_WEAK_CHOPPY` | Weak Uptrend · Choppy | Two steps forward, one back | ADX 20-30, RSI oscillating 50-70 |
|
||||
| `TU_PARABOLIC` | Parabolic Acceleration | Exponential price increase | Price far from MA, RSI>80, Volume surge |
|
||||
| `TU_EXHAUSTION` | Uptrend Exhaustion | New highs but weakening momentum | Price new high + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Heavy trend following, pyramid adding
|
||||
- Strong High Vol: Medium position, trailing stops
|
||||
- Weak Choppy: Light swing trading
|
||||
- Parabolic: Cautious, prepare to exit
|
||||
- Exhaustion: Reduce positions, prepare for reversal
|
||||
|
||||
### 3.2 Downtrend Sub-categories (5 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TD_STRONG_LOW_VOL` | Strong Downtrend · Low Vol | Steady decline, weak bounces | ADX>40, ATR%<2%, Bounce<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | Strong Downtrend · High Vol | Panic selling, wild swings | ADX>40, ATR%>5%, VIX spike |
|
||||
| `TD_WEAK_CHOPPY` | Weak Downtrend · Choppy | Grinding lower with bounces | ADX 20-30, RSI oscillating 30-50 |
|
||||
| `TD_CAPITULATION` | Capitulation | High volume crash, extreme fear | RSI<20, Volume>3x average |
|
||||
| `TD_EXHAUSTION` | Downtrend Exhaustion | New lows but selling pressure fading | Price new low + MACD/RSI divergence |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Strong Low Vol: Short trend following
|
||||
- Strong High Vol: Stay flat or light hedge
|
||||
- Weak Choppy: Wait for stabilization
|
||||
- Capitulation: Light bottom fishing possible
|
||||
- Exhaustion: Gradually build long positions
|
||||
|
||||
### 3.3 Range Sub-categories (4 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `RG_TIGHT_LOW_VOL` | Tight Range · Low Vol | Extreme contraction, coiling | BB Width<2%, ATR at new lows |
|
||||
| `RG_TIGHT_HIGH_VOL` | Tight Range · High Vol | Violent swings within range | BB Width<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | Wide Range · Low Vol | Large range, slow movement | BB Width>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | Wide Range · High Vol | Large range, fast movement | BB Width>5%, ATR%>3% |
|
||||
|
||||
**Strategy Matching:**
|
||||
- Tight Low Vol: Dense grid, wait for breakout
|
||||
- Tight High Vol: Fast grid, small frequent profits
|
||||
- Wide Low Vol: Sparse grid, patient holding
|
||||
- Wide High Vol: Swing trading, high profit targets
|
||||
|
||||
### 3.4 Transition (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `TR_BOTTOM_FORMING` | Bottom Forming | Decline slowing, testing support | Price stabilizing + Volume drying up + RSI divergence |
|
||||
| `TR_TOP_FORMING` | Top Forming | Rally slowing, testing resistance | Price stalling + Volume drying up + RSI divergence |
|
||||
|
||||
### 3.5 Breakout (2 Types)
|
||||
|
||||
| Code | Name | Technical Features | Quantitative Indicators |
|
||||
|------|------|-------------------|------------------------|
|
||||
| `BK_UPWARD` | Upward Breakout | Breaking resistance with volume | Price>Previous high, Volume>2x, BB breakout |
|
||||
| `BK_DOWNWARD` | Downward Breakout | Breaking support with volume | Price<Previous low, Volume>2x, BB breakdown |
|
||||
|
||||
---
|
||||
|
||||
## 4. Tertiary Classification (36 Ultra-fine Categories)
|
||||
|
||||
### 4.1 Trend Phase Classification
|
||||
|
||||
Uptrend lifecycle consists of 5 phases:
|
||||
|
||||
| Phase Code | Name | Description | Quantitative Criteria |
|
||||
|------------|------|-------------|----------------------|
|
||||
| `TU_S1_INITIATION` | Uptrend Initiation | First break above MA or previous high | MACD bullish cross, Price>EMA20 |
|
||||
| `TU_S2_ACCELERATION` | Uptrend Acceleration | Momentum increasing, slope steepening | MACD histogram expanding, ADX rising |
|
||||
| `TU_S3_MAIN_WAVE` | Main Wave | Sustained rise, shallow pullbacks | RSI 60-80, Pullbacks hold EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | Uptrend Exhaustion | Slowing momentum, divergences appearing | RSI divergence, MACD divergence |
|
||||
| `TU_S5_REVERSAL` | Trend Reversal | Breakdown, trend ending | Break below EMA50, MACD bearish cross |
|
||||
|
||||
Downtrend phases follow same pattern: `TD_S1` through `TD_S5`
|
||||
|
||||
### 4.2 Range Position Classification
|
||||
|
||||
| Position Code | Name | Description | Strategy Suggestion |
|
||||
|---------------|------|-------------|---------------------|
|
||||
| `RG_UPPER` | Upper Range | Price near resistance | Bias toward short |
|
||||
| `RG_MIDDLE` | Mid Range | Price near middle band | Neutral grid trading |
|
||||
| `RG_LOWER` | Lower Range | Price near support | Bias toward long |
|
||||
| `RG_SQUEEZE` | Squeeze Pattern | Highs and lows converging | Wait for direction |
|
||||
| `RG_EXPAND` | Expanding Pattern | Highs and lows diverging | Boundary reversal |
|
||||
|
||||
### 4.3 Volatility Grades
|
||||
|
||||
| Code | Name | ATR% | BB Width | Strategy Suggestion |
|
||||
|------|------|------|----------|---------------------|
|
||||
| `VOL_EXTREME_LOW` | Extreme Low Vol | <1% | <1.5% | Option selling |
|
||||
| `VOL_LOW` | Low Volatility | 1-2% | 1.5-2.5% | Grid / Mean reversion |
|
||||
| `VOL_NORMAL` | Normal Volatility | 2-3% | 2.5-4% | Trend following |
|
||||
| `VOL_HIGH` | High Volatility | 3-5% | 4-6% | Momentum / Breakout |
|
||||
| `VOL_EXTREME_HIGH` | Extreme High Vol | >5% | >6% | Reduce exposure / Hedge |
|
||||
|
||||
---
|
||||
|
||||
## 5. Complete State Encoding Rules
|
||||
|
||||
### 5.1 Encoding Format
|
||||
|
||||
```
|
||||
{Primary}_{Volatility}_{Phase}_{Position}
|
||||
```
|
||||
|
||||
### 5.2 Encoding Examples
|
||||
|
||||
| Full Code | Interpretation |
|
||||
|-----------|----------------|
|
||||
| `TU_LV_S3_M` | Uptrend_LowVol_MainWave_Middle |
|
||||
| `TD_HV_S2_L` | Downtrend_HighVol_Acceleration_Lower |
|
||||
| `RG_NV_SQ_U` | Range_NormalVol_Squeeze_Upper |
|
||||
| `BK_HV_UP_M` | Breakout_HighVol_Upward_Middle |
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Identification Indicators
|
||||
|
||||
### 6.1 Trend Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| ADX | 14-period Average Directional Index | >40 Strong, 25-40 Medium, <25 Weak/Range |
|
||||
| Trend Score | Composite EMA/MACD/Price structure | -100 to +100, Positive=Bullish, Negative=Bearish |
|
||||
| EMA Alignment | Relative position of EMA20/50/200 | Bullish/Bearish/Mixed alignment |
|
||||
|
||||
### 6.2 Volatility Indicators
|
||||
|
||||
| Indicator | Calculation | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| ATR Percent | ATR(14) / Current Price × 100% | Measure relative volatility |
|
||||
| BB Width | (Upper - Lower) / Middle × 100% | Measure price range |
|
||||
| Volatility Rank | Current vol percentile in history | Determine vol level |
|
||||
|
||||
### 6.3 Momentum Indicators
|
||||
|
||||
| Indicator | Calculation | Criteria |
|
||||
|-----------|-------------|----------|
|
||||
| RSI | 14-period Relative Strength Index | >70 Overbought, <30 Oversold, 50 Neutral |
|
||||
| MACD Histogram | MACD - Signal | Positive=Bullish momentum, Negative=Bearish |
|
||||
| Momentum Score | Composite RSI/MACD/Volume | Measure current momentum |
|
||||
|
||||
### 6.4 Structure Indicators
|
||||
|
||||
| Indicator | Description | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Swing Structure | HH/HL/LH/LL sequence | Determine trend structure |
|
||||
| Support/Resistance | Key price levels | Define trading range |
|
||||
| Volume Profile | Volume-price relationship | Validate price action |
|
||||
|
||||
---
|
||||
|
||||
## 7. Strategy Matching Matrix
|
||||
|
||||
### 7.1 Regime-Strategy Mapping
|
||||
|
||||
| Regime Type | Recommended Strategy | Position Size | Stop Loss |
|
||||
|-------------|---------------------|---------------|-----------|
|
||||
| Strong Uptrend · Low Vol | Trend following + Pyramid | 60-80% | ATR×2 |
|
||||
| Strong Uptrend · High Vol | Momentum + Quick profit | 40-60% | ATR×1.5 |
|
||||
| Uptrend Exhaustion | Reduce + Reversal short | 20-30% | Previous high |
|
||||
| Panic Decline | Wait or light bottom fish | 10-20% | Wide stop |
|
||||
| Low Vol Range | Grid trading | 50-70% | Range boundary |
|
||||
| High Vol Range | Swing trading | 30-50% | ATR×2 |
|
||||
| Squeeze Pattern | Wait for breakout | 10-20% | - |
|
||||
| Upward Breakout | Chase + Add on pullback | 50-70% | Breakout level |
|
||||
| Bottom Formation | Scale in gradually | 20-40% | New low |
|
||||
|
||||
### 7.2 Grid Strategy Parameter Matching
|
||||
|
||||
| Range Type | Grid Levels | Grid Spacing | Other Parameters |
|
||||
|------------|-------------|--------------|------------------|
|
||||
| Tight Low Vol | 30-50 levels | Small spacing | Enable Maker Only |
|
||||
| Tight High Vol | 15-25 levels | Small spacing | Fast execution mode |
|
||||
| Wide Low Vol | 10-20 levels | Large spacing | Patient execution |
|
||||
| Wide High Vol | 15-25 levels | Large spacing | High profit targets |
|
||||
| Squeeze Pattern | Pause grid | - | Wait for breakout signal |
|
||||
| Upper Range | Short bias | Medium | Increase sell weight |
|
||||
| Lower Range | Long bias | Medium | Increase buy weight |
|
||||
|
||||
---
|
||||
|
||||
## 8. Real-time Monitoring Guidelines
|
||||
|
||||
### 8.1 State Transition Triggers
|
||||
|
||||
| Current State | Trigger Condition | Transitions To |
|
||||
|---------------|-------------------|----------------|
|
||||
| Range | Price breakout + Volume + ADX rising | Breakout |
|
||||
| Uptrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Downtrend | RSI divergence + Volume decline | Exhaustion |
|
||||
| Breakout | Failed breakout, price returns | Range |
|
||||
| Exhaustion | Confirmed reversal breakout | Opposite trend |
|
||||
|
||||
### 8.2 Risk Control Rules
|
||||
|
||||
| Regime State | Max Position | Risk Per Trade | Special Rules |
|
||||
|--------------|--------------|----------------|---------------|
|
||||
| Strong Trend | 80% | 2% | Adding allowed |
|
||||
| Weak Trend | 50% | 1.5% | No adding |
|
||||
| Range | 60% | 1% | Diversified holding |
|
||||
| Transition | 30% | 1% | Reduce activity |
|
||||
| High Volatility | 40% | 0.5% | Wide stops |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix
|
||||
|
||||
### 9.1 Abbreviation Reference
|
||||
|
||||
| Abbrev | Full Form | Description |
|
||||
|--------|-----------|-------------|
|
||||
| TU | Trend Up | Upward trend |
|
||||
| TD | Trend Down | Downward trend |
|
||||
| RG | Range | Range-bound market |
|
||||
| TR | Transition | Trend transition |
|
||||
| BK | Breakout | Breakout pattern |
|
||||
| LV | Low Volatility | Low volatility regime |
|
||||
| HV | High Volatility | High volatility regime |
|
||||
| NV | Normal Volatility | Normal volatility regime |
|
||||
| XLV | Extreme Low Vol | Extremely low volatility |
|
||||
| XHV | Extreme High Vol | Extremely high volatility |
|
||||
|
||||
### 9.2 Document Information
|
||||
|
||||
- Version: v1.0
|
||||
- Created: January 2026
|
||||
- Applicable: Cryptocurrency, Forex, Stocks, and other financial markets
|
||||
|
||||
---
|
||||
|
||||
*This document is designed for market state identification and strategy matching in quantitative trading systems*
|
||||
281
docs/market-regime-classification-zh.md
Normal file
281
docs/market-regime-classification-zh.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# 市场行情精细分类体系
|
||||
|
||||
> 用于量化交易策略匹配的市场状态识别框架
|
||||
|
||||
---
|
||||
|
||||
## 一、分类维度概览
|
||||
|
||||
市场状态识别需要从多个维度进行分析:
|
||||
|
||||
| 维度 | 子维度 | 说明 |
|
||||
|------|--------|------|
|
||||
| **趋势维度** | 方向、强度 | 判断市场运动方向和力度 |
|
||||
| **波动维度** | 幅度、频率 | 衡量价格波动特征 |
|
||||
| **结构维度** | 形态、阶段 | 识别市场结构和所处周期 |
|
||||
|
||||
---
|
||||
|
||||
## 二、一级分类(5大类)
|
||||
|
||||
### 2.1 分类总览
|
||||
|
||||
| 代码 | 名称 | 核心特征 | 适合策略 |
|
||||
|------|------|----------|----------|
|
||||
| `TREND_UP` | 上涨趋势 | 高点/低点持续抬升 | 趋势跟踪、突破追涨 |
|
||||
| `TREND_DOWN` | 下跌趋势 | 高点/低点持续降低 | 趋势跟踪、做空策略 |
|
||||
| `RANGE` | 震荡区间 | 价格在区间内波动 | 网格交易、均值回归 |
|
||||
| `TRANSITION` | 趋势转换 | 方向不明确的过渡期 | 观望、小仓位试探 |
|
||||
| `BREAKOUT` | 突破行情 | 价格突破关键位置 | 突破追踪策略 |
|
||||
|
||||
### 2.2 识别指标
|
||||
|
||||
- **ADX(平均方向指数)**:衡量趋势强度
|
||||
- ADX > 25:存在明确趋势
|
||||
- ADX < 20:震荡市场
|
||||
- **EMA排列**:判断趋势方向
|
||||
- EMA20 > EMA50 > EMA200:多头排列
|
||||
- EMA20 < EMA50 < EMA200:空头排列
|
||||
|
||||
---
|
||||
|
||||
## 三、二级分类(18细分类)
|
||||
|
||||
### 3.1 上涨趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TU_STRONG_LOW_VOL` | 强势上涨·低波动 | 稳步上涨,回调幅度小 | ADX>40, ATR%<2%, 回调<38.2% |
|
||||
| `TU_STRONG_HIGH_VOL` | 强势上涨·高波动 | 快速拉升,波动剧烈 | ADX>40, ATR%>4%, MACD柱放大 |
|
||||
| `TU_WEAK_CHOPPY` | 弱势上涨·震荡 | 涨三退二,反复磨蹭 | ADX 20-30, RSI在50-70震荡 |
|
||||
| `TU_PARABOLIC` | 抛物线加速 | 指数级加速上涨 | 价格远离均线, RSI>80, 成交量放大 |
|
||||
| `TU_EXHAUSTION` | 上涨衰竭 | 创新高但动能减弱 | 价格新高 + MACD/RSI顶背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:重仓趋势跟踪,金字塔加仓
|
||||
- 强势高波动:中等仓位,设置移动止盈
|
||||
- 弱势震荡:轻仓波段,高抛低吸
|
||||
- 抛物线加速:谨慎追涨,准备离场
|
||||
- 上涨衰竭:减仓观望,准备反转做空
|
||||
|
||||
### 3.2 下跌趋势细分(5种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TD_STRONG_LOW_VOL` | 强势下跌·低波动 | 稳步下跌,反弹无力 | ADX>40, ATR%<2%, 反弹<38.2% |
|
||||
| `TD_STRONG_HIGH_VOL` | 强势下跌·高波动 | 恐慌抛售,波动剧烈 | ADX>40, ATR%>5%, 恐慌指数飙升 |
|
||||
| `TD_WEAK_CHOPPY` | 弱势下跌·震荡 | 跌跌涨涨,磨底过程 | ADX 20-30, RSI在30-50震荡 |
|
||||
| `TD_CAPITULATION` | 恐慌投降 | 放量暴跌,情绪极端 | RSI<20, 成交量>3倍均量 |
|
||||
| `TD_EXHAUSTION` | 下跌衰竭 | 创新低但卖压减弱 | 价格新低 + MACD/RSI底背离 |
|
||||
|
||||
**策略匹配:**
|
||||
- 强势低波动:空头趋势跟踪
|
||||
- 强势高波动:观望或轻仓对冲
|
||||
- 弱势震荡:等待企稳信号
|
||||
- 恐慌投降:极端情况可轻仓抄底
|
||||
- 下跌衰竭:逐步建立多头仓位
|
||||
|
||||
### 3.3 震荡区间细分(4种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `RG_TIGHT_LOW_VOL` | 窄幅震荡·低波动 | 极度收敛,蓄势待发 | 布林带宽度<2%, ATR创新低 |
|
||||
| `RG_TIGHT_HIGH_VOL` | 窄幅震荡·高波动 | 区间内剧烈波动 | 布林带宽度<3%, ATR%>3% |
|
||||
| `RG_WIDE_LOW_VOL` | 宽幅震荡·低波动 | 大区间慢速波动 | 布林带宽度>5%, ATR%<2% |
|
||||
| `RG_WIDE_HIGH_VOL` | 宽幅震荡·高波动 | 大区间快速波动 | 布林带宽度>5%, ATR%>3% |
|
||||
|
||||
**策略匹配:**
|
||||
- 窄幅低波动:密集网格,等待突破
|
||||
- 窄幅高波动:快速网格,小利润多次
|
||||
- 宽幅低波动:稀疏网格,耐心持有
|
||||
- 宽幅高波动:波段交易,高利润目标
|
||||
|
||||
### 3.4 转换过渡(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `TR_BOTTOM_FORMING` | 底部形成中 | 下跌放缓,试探支撑 | 价格止跌 + 成交量萎缩 + RSI底背离 |
|
||||
| `TR_TOP_FORMING` | 顶部形成中 | 上涨放缓,试探压力 | 价格滞涨 + 成交量萎缩 + RSI顶背离 |
|
||||
|
||||
### 3.5 突破行情(2种)
|
||||
|
||||
| 代码 | 名称 | 技术特征 | 量化指标 |
|
||||
|------|------|----------|----------|
|
||||
| `BK_UPWARD` | 向上突破 | 突破阻力位并放量 | 价格>前高, 成交量>2倍, 布林带突破 |
|
||||
| `BK_DOWNWARD` | 向下突破 | 跌破支撑位并放量 | 价格<前低, 成交量>2倍, 布林带跌破 |
|
||||
|
||||
---
|
||||
|
||||
## 四、三级分类(36超细分类)
|
||||
|
||||
### 4.1 趋势阶段细分
|
||||
|
||||
上涨趋势生命周期分为5个阶段:
|
||||
|
||||
| 阶段代码 | 名称 | 特征描述 | 量化判断标准 |
|
||||
|----------|------|----------|--------------|
|
||||
| `TU_S1_INITIATION` | 上涨启动期 | 首次突破均线或前高 | MACD金叉, 价格突破EMA20 |
|
||||
| `TU_S2_ACCELERATION` | 上涨加速期 | 动能增强,斜率加大 | MACD柱持续增大, ADX上升 |
|
||||
| `TU_S3_MAIN_WAVE` | 主升浪阶段 | 持续上涨,回调幅度浅 | RSI维持60-80, 回调不破EMA20 |
|
||||
| `TU_S4_EXHAUSTION` | 上涨衰竭期 | 涨速放缓,出现背离 | RSI顶背离, MACD顶背离 |
|
||||
| `TU_S5_REVERSAL` | 趋势反转期 | 破位下跌,趋势结束 | 跌破EMA50, MACD死叉 |
|
||||
|
||||
下跌趋势同理,代码为 `TD_S1` 至 `TD_S5`
|
||||
|
||||
### 4.2 震荡位置细分
|
||||
|
||||
| 位置代码 | 名称 | 特征描述 | 策略建议 |
|
||||
|----------|------|----------|----------|
|
||||
| `RG_UPPER` | 区间上沿震荡 | 价格接近阻力位 | 偏空操作为主 |
|
||||
| `RG_MIDDLE` | 区间中部震荡 | 价格在中轨附近 | 双向网格交易 |
|
||||
| `RG_LOWER` | 区间下沿震荡 | 价格接近支撑位 | 偏多操作为主 |
|
||||
| `RG_SQUEEZE` | 收敛三角震荡 | 高低点逐渐收窄 | 等待方向选择 |
|
||||
| `RG_EXPAND` | 扩散三角震荡 | 高低点逐渐扩张 | 边界反转操作 |
|
||||
|
||||
### 4.3 波动率等级
|
||||
|
||||
| 代码 | 名称 | ATR百分比 | 布林带宽度 | 策略建议 |
|
||||
|------|------|-----------|------------|----------|
|
||||
| `VOL_EXTREME_LOW` | 极低波动 | <1% | <1.5% | 期权卖方策略 |
|
||||
| `VOL_LOW` | 低波动 | 1-2% | 1.5-2.5% | 网格/均值回归 |
|
||||
| `VOL_NORMAL` | 正常波动 | 2-3% | 2.5-4% | 趋势跟踪 |
|
||||
| `VOL_HIGH` | 高波动 | 3-5% | 4-6% | 动量/突破 |
|
||||
| `VOL_EXTREME_HIGH` | 极高波动 | >5% | >6% | 减仓/对冲 |
|
||||
|
||||
---
|
||||
|
||||
## 五、完整状态编码规则
|
||||
|
||||
### 5.1 编码格式
|
||||
|
||||
```
|
||||
{一级分类}_{波动等级}_{阶段}_{位置}
|
||||
```
|
||||
|
||||
### 5.2 编码示例
|
||||
|
||||
| 完整代码 | 含义解释 |
|
||||
|----------|----------|
|
||||
| `TU_LV_S3_M` | 上涨趋势_低波动_主升浪_中部位置 |
|
||||
| `TD_HV_S2_L` | 下跌趋势_高波动_加速期_下部位置 |
|
||||
| `RG_NV_SQ_U` | 震荡区间_正常波动_收敛形态_上沿位置 |
|
||||
| `BK_HV_UP_M` | 突破行情_高波动_向上突破_中部位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、核心识别指标
|
||||
|
||||
### 6.1 趋势指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| ADX | 14周期平均方向指数 | >40强趋势, 25-40中等, <25弱/震荡 |
|
||||
| 趋势评分 | 综合EMA/MACD/价格结构 | -100到+100, 正数多头,负数空头 |
|
||||
| EMA排列 | EMA20/50/200相对位置 | 多头排列/空头排列/混乱 |
|
||||
|
||||
### 6.2 波动指标
|
||||
|
||||
| 指标 | 计算方法 | 用途 |
|
||||
|------|----------|------|
|
||||
| ATR百分比 | ATR(14) / 当前价格 × 100% | 衡量相对波动幅度 |
|
||||
| 布林带宽度 | (上轨-下轨) / 中轨 × 100% | 衡量价格波动区间 |
|
||||
| 波动率排名 | 当前波动在历史中的分位 | 判断波动率高低 |
|
||||
|
||||
### 6.3 动量指标
|
||||
|
||||
| 指标 | 计算方法 | 判断标准 |
|
||||
|------|----------|----------|
|
||||
| RSI | 14周期相对强弱指数 | >70超买, <30超卖, 50中性 |
|
||||
| MACD柱 | MACD - Signal | 正数多头动能,负数空头动能 |
|
||||
| 动量评分 | 综合RSI/MACD/成交量 | 衡量当前动能强弱 |
|
||||
|
||||
### 6.4 结构指标
|
||||
|
||||
| 指标 | 说明 | 用途 |
|
||||
|------|------|------|
|
||||
| 高低点结构 | HH/HL/LH/LL序列 | 判断趋势结构 |
|
||||
| 支撑阻力位 | 关键价格水平 | 确定交易区间 |
|
||||
| 成交量形态 | 量价配合关系 | 验证价格走势 |
|
||||
|
||||
---
|
||||
|
||||
## 七、策略匹配矩阵
|
||||
|
||||
### 7.1 行情类型与策略对应
|
||||
|
||||
| 行情类型 | 推荐策略 | 建议仓位 | 止损设置 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强势上涨·低波动 | 趋势跟踪+金字塔加仓 | 60-80% | ATR×2 |
|
||||
| 强势上涨·高波动 | 动量突破+快速止盈 | 40-60% | ATR×1.5 |
|
||||
| 上涨衰竭期 | 减仓+反转信号做空 | 20-30% | 前高 |
|
||||
| 恐慌下跌 | 观望或轻仓抄底 | 10-20% | 宽止损 |
|
||||
| 低波动震荡 | 网格交易 | 50-70% | 区间边界 |
|
||||
| 高波动震荡 | 波段高抛低吸 | 30-50% | ATR×2 |
|
||||
| 收敛等待 | 蓄势等突破 | 10-20% | - |
|
||||
| 向上突破 | 追涨+回踩加仓 | 50-70% | 突破位 |
|
||||
| 底部形成 | 分批建仓 | 20-40% | 新低 |
|
||||
|
||||
### 7.2 网格策略参数匹配
|
||||
|
||||
| 震荡类型 | 网格层数 | 网格间距 | 其他参数 |
|
||||
|----------|----------|----------|----------|
|
||||
| 窄幅低波动 | 30-50层 | 小间距 | 启用Maker Only |
|
||||
| 窄幅高波动 | 15-25层 | 小间距 | 快速成交模式 |
|
||||
| 宽幅低波动 | 10-20层 | 大间距 | 耐心等待成交 |
|
||||
| 宽幅高波动 | 15-25层 | 大间距 | 高利润目标 |
|
||||
| 收敛形态 | 暂停网格 | - | 等待突破信号 |
|
||||
| 区间上沿 | 偏空配置 | 中等 | 卖单权重增加 |
|
||||
| 区间下沿 | 偏多配置 | 中等 | 买单权重增加 |
|
||||
|
||||
---
|
||||
|
||||
## 八、实时监控建议
|
||||
|
||||
### 8.1 状态转换触发条件
|
||||
|
||||
| 当前状态 | 触发条件 | 转换到 |
|
||||
|----------|----------|--------|
|
||||
| 震荡区间 | 价格突破+放量+ADX上升 | 突破行情 |
|
||||
| 上涨趋势 | RSI顶背离+成交量萎缩 | 上涨衰竭 |
|
||||
| 下跌趋势 | RSI底背离+成交量萎缩 | 下跌衰竭 |
|
||||
| 突破行情 | 突破失败回落 | 震荡区间 |
|
||||
| 趋势衰竭 | 反向突破确认 | 反向趋势 |
|
||||
|
||||
### 8.2 风险控制规则
|
||||
|
||||
| 行情状态 | 最大仓位 | 单笔风险 | 特殊规则 |
|
||||
|----------|----------|----------|----------|
|
||||
| 强趋势 | 80% | 2% | 可加仓 |
|
||||
| 弱趋势 | 50% | 1.5% | 不加仓 |
|
||||
| 震荡 | 60% | 1% | 分散持仓 |
|
||||
| 转换期 | 30% | 1% | 减少操作 |
|
||||
| 高波动 | 40% | 0.5% | 宽止损 |
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 缩写对照表
|
||||
|
||||
| 缩写 | 英文全称 | 中文含义 |
|
||||
|------|----------|----------|
|
||||
| TU | Trend Up | 上涨趋势 |
|
||||
| TD | Trend Down | 下跌趋势 |
|
||||
| RG | Range | 震荡区间 |
|
||||
| TR | Transition | 趋势转换 |
|
||||
| BK | Breakout | 突破行情 |
|
||||
| LV | Low Volatility | 低波动 |
|
||||
| HV | High Volatility | 高波动 |
|
||||
| NV | Normal Volatility | 正常波动 |
|
||||
| XLV | Extreme Low Vol | 极低波动 |
|
||||
| XHV | Extreme High Vol | 极高波动 |
|
||||
|
||||
### 9.2 版本信息
|
||||
|
||||
- 文档版本:v1.0
|
||||
- 创建日期:2026年1月
|
||||
- 适用范围:加密货币、外汇、股票等金融市场
|
||||
|
||||
---
|
||||
|
||||
*本文档用于量化交易系统的市场状态识别和策略匹配*
|
||||
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
151
docs/plans/2026-01-17-grid-market-regime-design.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# 网格策略市场状态识别与风控设计
|
||||
|
||||
## 概述
|
||||
|
||||
增强网格策略的市场状态识别能力,实现震荡/趋势的精准判断,并根据不同震荡级别自动调整网格参数和风控策略。
|
||||
|
||||
---
|
||||
|
||||
## 一、市场状态识别
|
||||
|
||||
### 1.1 识别维度(3个)
|
||||
|
||||
| 维度 | 指标 | 作用 |
|
||||
|------|------|------|
|
||||
| 价格波动 | ATR14 + Bollinger带宽 | 判断震荡幅度 |
|
||||
| 趋势强度 | EMA20/50距离 + MACD | 判断是否有趋势 |
|
||||
| 动量 | RSI14 + 1h/4h涨跌幅 | 判断超买超卖 |
|
||||
|
||||
### 1.2 箱体指标(新增)
|
||||
|
||||
基于1小时K线的多周期Donchian通道:
|
||||
|
||||
| 箱体级别 | 周期 | 覆盖时间 | 用途 |
|
||||
|----------|------|----------|------|
|
||||
| 短期箱体 | 72根1小时 | 3天 | 日内波动边界 |
|
||||
| 中期箱体 | 240根1小时 | 10天 | 周级别震荡区间 |
|
||||
| 长期箱体 | 500根1小时 | ~21天 | 大级别趋势边界 |
|
||||
|
||||
### 1.3 判断方式
|
||||
|
||||
由AI综合分析以上指标 + 原始K线序列 + 箱体位置,输出市场状态判断。
|
||||
|
||||
---
|
||||
|
||||
## 二、震荡分级与网格策略
|
||||
|
||||
### 2.1 四级震荡分类
|
||||
|
||||
| 级别 | 特征 | 判断依据 |
|
||||
|------|------|----------|
|
||||
| 窄幅震荡 | 价格在短期箱体内小幅波动 | Bollinger带宽 < 2%,ATR低 |
|
||||
| 标准震荡 | 价格在中期箱体内正常波动 | Bollinger带宽 2-3%,ATR正常 |
|
||||
| 宽幅震荡 | 价格接近中期箱体边缘 | Bollinger带宽 3-4%,ATR较高 |
|
||||
| 剧烈震荡 | 价格接近长期箱体边缘 | Bollinger带宽 > 4%,ATR高 |
|
||||
|
||||
### 2.2 各级别对应的网格策略
|
||||
|
||||
| 级别 | 网格密度 | 网格范围 | 单格仓位 | 总仓位上限 | 有效杠杆上限 |
|
||||
|------|----------|----------|----------|------------|--------------|
|
||||
| 窄幅震荡 | 密集 | 窄 | 小 | 30-40% | 2x |
|
||||
| 标准震荡 | 正常 | 中等 | 正常 | 60-70% | 3-4x |
|
||||
| 宽幅震荡 | 稀疏 | 宽 | 正常 | 50-60% | 3x |
|
||||
| 剧烈震荡 | 最稀疏 | 最宽 | 小 | 30-40% | 2x |
|
||||
|
||||
**核心原则:**
|
||||
- 窄幅震荡:单格仓位小 + 总仓位上限低(防击穿风险)
|
||||
- 剧烈震荡:同样保守(随时可能变趋势)
|
||||
- 标准震荡:才是放量的最佳时机
|
||||
|
||||
---
|
||||
|
||||
## 三、突破处理与恢复机制
|
||||
|
||||
### 3.1 突破判断与处理
|
||||
|
||||
**确认方式:** 收盘价突破箱体后,持续3根1小时K线不回箱体
|
||||
|
||||
| 箱体级别 | 突破处理 |
|
||||
|----------|----------|
|
||||
| 短期箱体突破 | 降低仓位到 50% |
|
||||
| 中期箱体突破 | 暂停网格 + 取消挂单 |
|
||||
| 长期箱体突破 | 暂停网格 + 取消挂单 + 平掉所有持仓 |
|
||||
|
||||
### 3.2 假突破恢复
|
||||
|
||||
**价格回到箱体内 → 以50%仓位恢复网格**
|
||||
|
||||
---
|
||||
|
||||
## 四、前端风控面板
|
||||
|
||||
### 4.1 需要展示的信息
|
||||
|
||||
| 类别 | 显示内容 |
|
||||
|------|----------|
|
||||
| 杠杆信息 | 当前杠杆、有效杠杆、系统推荐杠杆 |
|
||||
| 仓位信息 | 当前仓位、最大仓位、仓位占比 |
|
||||
| 爆仓信息 | 爆仓价格、爆仓距离(%) |
|
||||
| 市场状态 | 当前震荡级别(窄幅/标准/宽幅/剧烈) |
|
||||
| 箱体状态 | 短期/中期/长期箱体上下沿、当前价格位置 |
|
||||
|
||||
---
|
||||
|
||||
## 五、实现要点
|
||||
|
||||
### 5.1 后端新增
|
||||
|
||||
1. **箱体指标计算** (`market/data.go`)
|
||||
- 新增 `calculateDonchian(klines, period)` 函数
|
||||
- 返回 upper(最高价), lower(最低价)
|
||||
- 支持72/240/500三个周期
|
||||
|
||||
2. **市场状态评估** (`kernel/grid_engine.go`)
|
||||
- 更新AI prompt,加入箱体指标和K线序列
|
||||
- AI输出震荡级别判断
|
||||
|
||||
3. **网格参数动态调整** (`trader/auto_trader_grid.go`)
|
||||
- 根据震荡级别自动调整:网格密度、范围、仓位、杠杆
|
||||
- 实现有效杠杆上限控制
|
||||
|
||||
4. **突破处理逻辑** (`trader/auto_trader_grid.go`)
|
||||
- 实现三级箱体突破检测
|
||||
- 实现3根K线确认逻辑
|
||||
- 实现降级恢复机制
|
||||
|
||||
### 5.2 前端新增
|
||||
|
||||
1. **风控面板组件**
|
||||
- 杠杆信息展示
|
||||
- 仓位信息展示
|
||||
- 爆仓信息展示
|
||||
- 市场状态展示
|
||||
- 箱体状态可视化
|
||||
|
||||
### 5.3 数据模型更新
|
||||
|
||||
1. **GridConfigModel** 新增字段:
|
||||
- `EffectiveLeverageLimit` - 有效杠杆上限
|
||||
- `ShortBoxPeriod` - 短期箱体周期 (默认72)
|
||||
- `MidBoxPeriod` - 中期箱体周期 (默认240)
|
||||
- `LongBoxPeriod` - 长期箱体周期 (默认500)
|
||||
|
||||
2. **GridInstanceModel** 新增字段:
|
||||
- `CurrentRegimeLevel` - 当前震荡级别 (narrow/standard/wide/volatile)
|
||||
- `ShortBoxUpper/Lower` - 短期箱体上下沿
|
||||
- `MidBoxUpper/Lower` - 中期箱体上下沿
|
||||
- `LongBoxUpper/Lower` - 长期箱体上下沿
|
||||
- `BreakoutStatus` - 突破状态 (none/short/mid/long)
|
||||
- `BreakoutConfirmCount` - 突破确认K线计数
|
||||
|
||||
---
|
||||
|
||||
## 六、风险控制总结
|
||||
|
||||
| 控制点 | 机制 |
|
||||
|--------|------|
|
||||
| 仓位控制 | 根据震荡级别限制总仓位上限 (30-70%) |
|
||||
| 杠杆控制 | 根据震荡级别限制有效杠杆 (2-4x) |
|
||||
| 突破保护 | 三级箱体突破分级处理 |
|
||||
| 假突破恢复 | 50%仓位降级恢复 |
|
||||
| 爆仓预防 | 前端展示爆仓距离,系统自动限制杠杆 |
|
||||
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
1655
docs/plans/2026-01-17-grid-market-regime-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -130,7 +130,8 @@ type Context struct {
|
||||
// Decision AI trading decision
|
||||
type Decision struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
Action string `json:"action"` // Standard: "open_long", "open_short", "close_long", "close_short", "hold", "wait"
|
||||
// Grid actions: "place_buy_limit", "place_sell_limit", "cancel_order", "cancel_all_orders", "pause_grid", "resume_grid", "adjust_grid"
|
||||
|
||||
// Opening position parameters
|
||||
Leverage int `json:"leverage,omitempty"`
|
||||
@@ -138,6 +139,12 @@ type Decision struct {
|
||||
StopLoss float64 `json:"stop_loss,omitempty"`
|
||||
TakeProfit float64 `json:"take_profit,omitempty"`
|
||||
|
||||
// Grid trading parameters
|
||||
Price float64 `json:"price,omitempty"` // Limit order price (for grid)
|
||||
Quantity float64 `json:"quantity,omitempty"` // Order quantity (for grid)
|
||||
LevelIndex int `json:"level_index,omitempty"` // Grid level index
|
||||
OrderID string `json:"order_id,omitempty"` // Order ID (for cancel)
|
||||
|
||||
// Common parameters
|
||||
Confidence int `json:"confidence,omitempty"` // Confidence level (0-100)
|
||||
RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk
|
||||
|
||||
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,7 +1,5 @@
|
||||
package kernel
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ============================================================================
|
||||
// Trading Data Schema - 交易数据字典
|
||||
// ============================================================================
|
||||
@@ -481,18 +479,6 @@ func getSchemaPromptZH() string {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// 交易规则
|
||||
prompt += "\n## ⚖️ 交易规则\n\n"
|
||||
prompt += "### 风险管理\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### 出场信号\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||
}
|
||||
|
||||
// OI解读
|
||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
@@ -500,14 +486,6 @@ func getSchemaPromptZH() string {
|
||||
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
|
||||
// 常见错误
|
||||
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
|
||||
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
|
||||
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
|
||||
prompt += formatFieldDefEN(key, field)
|
||||
}
|
||||
|
||||
// Trading Rules
|
||||
prompt += "\n## ⚖️ Trading Rules\n\n"
|
||||
prompt += "### Risk Management\n"
|
||||
for name, rule := range TradingRules.RiskManagement {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
prompt += "\n### Exit Signals\n"
|
||||
for name, rule := range TradingRules.ExitSignals {
|
||||
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||
}
|
||||
|
||||
// OI Interpretation
|
||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
|
||||
@@ -559,14 +525,6 @@ func getSchemaPromptEN() string {
|
||||
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
|
||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
|
||||
|
||||
// Common Mistakes
|
||||
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
|
||||
for i, mistake := range CommonMistakes {
|
||||
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
|
||||
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
|
||||
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
|
||||
@@ -147,10 +147,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
||||
"交易指标",
|
||||
"持仓指标",
|
||||
"市场数据",
|
||||
"交易规则",
|
||||
"风险管理",
|
||||
"持仓量(OI)变化解读",
|
||||
"常见错误",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
@@ -174,10 +171,7 @@ func TestGetSchemaPrompt(t *testing.T) {
|
||||
"Trade Metrics",
|
||||
"Position Metrics",
|
||||
"Market Data",
|
||||
"Trading Rules",
|
||||
"Risk Management",
|
||||
"Open Interest",
|
||||
"Common Mistakes",
|
||||
}
|
||||
|
||||
for _, keyword := range mustContain {
|
||||
|
||||
2
main.go
2
main.go
@@ -78,7 +78,7 @@ func main() {
|
||||
logger.Fatalf("❌ Failed to initialize database: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
backtest.UseDatabase(st.DB())
|
||||
backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
|
||||
|
||||
// Initialize installation ID for experience improvement (anonymous statistics)
|
||||
initInstallationID(st)
|
||||
|
||||
@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
// Concurrently fetch data for each trader
|
||||
for i, t := range traders {
|
||||
go func(index int, trader *trader.AutoTrader) {
|
||||
// Set timeout to 3 seconds for single trader
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use channel for timeout control
|
||||
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case err := <-errorChan:
|
||||
// Failed to get account info
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err)
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID())
|
||||
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
|
||||
@@ -1210,3 +1210,91 @@ func ExportCalculateATR(klines []Kline, period int) float64 {
|
||||
func ExportCalculateBOLL(klines []Kline, period int, multiplier float64) (upper, middle, lower float64) {
|
||||
return calculateBOLL(klines, period, multiplier)
|
||||
}
|
||||
|
||||
// calculateDonchian calculates Donchian channel (highest high, lowest low) for given period
|
||||
func calculateDonchian(klines []Kline, period int) (upper, lower float64) {
|
||||
if len(klines) == 0 || period <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Use all available klines if period > len(klines)
|
||||
start := len(klines) - period
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
|
||||
upper = klines[start].High
|
||||
lower = klines[start].Low
|
||||
|
||||
for i := start + 1; i < len(klines); i++ {
|
||||
if klines[i].High > upper {
|
||||
upper = klines[i].High
|
||||
}
|
||||
if klines[i].Low < lower {
|
||||
lower = klines[i].Low
|
||||
}
|
||||
}
|
||||
|
||||
return upper, lower
|
||||
}
|
||||
|
||||
// ExportCalculateDonchian exports calculateDonchian for testing
|
||||
func ExportCalculateDonchian(klines []Kline, period int) (float64, float64) {
|
||||
return calculateDonchian(klines, period)
|
||||
}
|
||||
|
||||
// Box period constants (in 1h candles)
|
||||
const (
|
||||
ShortBoxPeriod = 72 // 3 days of 1h candles
|
||||
MidBoxPeriod = 240 // 10 days of 1h candles
|
||||
LongBoxPeriod = 500 // ~21 days of 1h candles
|
||||
)
|
||||
|
||||
// calculateBoxData calculates multi-period box data from klines
|
||||
func calculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
box := &BoxData{
|
||||
CurrentPrice: currentPrice,
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return box
|
||||
}
|
||||
|
||||
box.ShortUpper, box.ShortLower = calculateDonchian(klines, ShortBoxPeriod)
|
||||
box.MidUpper, box.MidLower = calculateDonchian(klines, MidBoxPeriod)
|
||||
box.LongUpper, box.LongLower = calculateDonchian(klines, LongBoxPeriod)
|
||||
|
||||
return box
|
||||
}
|
||||
|
||||
// ExportCalculateBoxData exports calculateBoxData for testing
|
||||
func ExportCalculateBoxData(klines []Kline, currentPrice float64) *BoxData {
|
||||
return calculateBoxData(klines, currentPrice)
|
||||
}
|
||||
|
||||
// GetBoxData fetches 1h klines and calculates box data for a symbol
|
||||
func GetBoxData(symbol string) (*BoxData, error) {
|
||||
symbol = Normalize(symbol)
|
||||
|
||||
// Fetch 500 1h klines
|
||||
var klines []Kline
|
||||
var err error
|
||||
|
||||
if IsXyzDexAsset(symbol) {
|
||||
klines, err = getKlinesFromHyperliquid(symbol, "1h", LongBoxPeriod)
|
||||
} else {
|
||||
klines, err = getKlinesFromCoinAnk(symbol, "1h", LongBoxPeriod)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 1h klines: %w", err)
|
||||
}
|
||||
|
||||
if len(klines) == 0 {
|
||||
return nil, fmt.Errorf("no kline data available")
|
||||
}
|
||||
|
||||
currentPrice := klines[len(klines)-1].Close
|
||||
|
||||
return calculateBoxData(klines, currentPrice), nil
|
||||
}
|
||||
|
||||
@@ -500,3 +500,86 @@ func TestIsStaleData_EmptyKlines(t *testing.T) {
|
||||
t.Error("Expected false for empty klines, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian(t *testing.T) {
|
||||
// Create test klines with known high/low values
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
{High: 102, Low: 92},
|
||||
{High: 108, Low: 85},
|
||||
{High: 103, Low: 91},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 5)
|
||||
|
||||
if upper != 108 {
|
||||
t.Errorf("Expected upper = 108, got %v", upper)
|
||||
}
|
||||
if lower != 85 {
|
||||
t.Errorf("Expected lower = 85, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_PartialPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
{High: 105, Low: 88},
|
||||
}
|
||||
|
||||
upper, lower := ExportCalculateDonchian(klines, 10)
|
||||
|
||||
// Should use all available klines when period > len(klines)
|
||||
if upper != 105 {
|
||||
t.Errorf("Expected upper = 105, got %v", upper)
|
||||
}
|
||||
if lower != 88 {
|
||||
t.Errorf("Expected lower = 88, got %v", lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDonchian_InvalidPeriod(t *testing.T) {
|
||||
klines := []Kline{
|
||||
{High: 100, Low: 90},
|
||||
}
|
||||
|
||||
// Zero period should return (0, 0)
|
||||
upper, lower := ExportCalculateDonchian(klines, 0)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for zero period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
|
||||
// Negative period should return (0, 0)
|
||||
upper, lower = ExportCalculateDonchian(klines, -1)
|
||||
if upper != 0 || lower != 0 {
|
||||
t.Errorf("Expected (0, 0) for negative period, got (%v, %v)", upper, lower)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateBoxData(t *testing.T) {
|
||||
// Create synthetic kline data
|
||||
klines := make([]Kline, 500)
|
||||
for i := 0; i < 500; i++ {
|
||||
basePrice := 100.0
|
||||
klines[i] = Kline{
|
||||
High: basePrice + float64(i%10),
|
||||
Low: basePrice - float64(i%10),
|
||||
Close: basePrice,
|
||||
}
|
||||
}
|
||||
|
||||
box := ExportCalculateBoxData(klines, 100.0)
|
||||
|
||||
if box.ShortUpper == 0 || box.ShortLower == 0 {
|
||||
t.Error("Short box should not be zero")
|
||||
}
|
||||
if box.MidUpper == 0 || box.MidLower == 0 {
|
||||
t.Error("Mid box should not be zero")
|
||||
}
|
||||
if box.LongUpper == 0 || box.LongLower == 0 {
|
||||
t.Error("Long box should not be zero")
|
||||
}
|
||||
if box.CurrentPrice != 100.0 {
|
||||
t.Errorf("Expected CurrentPrice = 100.0, got %v", box.CurrentPrice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,3 +187,42 @@ var config = Config{
|
||||
},
|
||||
UpdateInterval: 60, // 1 minute
|
||||
}
|
||||
|
||||
// BoxData represents multi-period Donchian channel (box) data
|
||||
type BoxData struct {
|
||||
// Short-term box (72 1h candles = 3 days)
|
||||
ShortUpper float64 `json:"short_upper"`
|
||||
ShortLower float64 `json:"short_lower"`
|
||||
|
||||
// Mid-term box (240 1h candles = 10 days)
|
||||
MidUpper float64 `json:"mid_upper"`
|
||||
MidLower float64 `json:"mid_lower"`
|
||||
|
||||
// Long-term box (500 1h candles = ~21 days)
|
||||
LongUpper float64 `json:"long_upper"`
|
||||
LongLower float64 `json:"long_lower"`
|
||||
|
||||
// Current price position relative to boxes
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
}
|
||||
|
||||
// RegimeLevel represents the ranging classification level
|
||||
type RegimeLevel string
|
||||
|
||||
const (
|
||||
RegimeLevelNarrow RegimeLevel = "narrow" // 窄幅震荡
|
||||
RegimeLevelStandard RegimeLevel = "standard" // 标准震荡
|
||||
RegimeLevelWide RegimeLevel = "wide" // 宽幅震荡
|
||||
RegimeLevelVolatile RegimeLevel = "volatile" // 剧烈震荡
|
||||
RegimeLevelTrending RegimeLevel = "trending" // 趋势
|
||||
)
|
||||
|
||||
// BreakoutLevel represents which box level has been broken
|
||||
type BreakoutLevel string
|
||||
|
||||
const (
|
||||
BreakoutNone BreakoutLevel = "none"
|
||||
BreakoutShort BreakoutLevel = "short"
|
||||
BreakoutMid BreakoutLevel = "mid"
|
||||
BreakoutLong BreakoutLevel = "long"
|
||||
)
|
||||
|
||||
8
railway.toml
Normal file
8
railway.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
dockerfilePath = "Dockerfile.railway"
|
||||
|
||||
[deploy]
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 60
|
||||
restartPolicyType = "ON_FAILURE"
|
||||
restartPolicyMaxRetries = 3
|
||||
57
railway/start.sh
Normal file
57
railway/start.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Railway 会设置 PORT 环境变量
|
||||
export PORT=${PORT:-8080}
|
||||
echo "🚀 Starting NOFX on port $PORT..."
|
||||
|
||||
# 生成加密密钥(如果没有设置)
|
||||
if [ -z "$RSA_PRIVATE_KEY" ]; then
|
||||
export RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null)
|
||||
fi
|
||||
if [ -z "$DATA_ENCRYPTION_KEY" ]; then
|
||||
export DATA_ENCRYPTION_KEY=$(openssl rand -base64 32)
|
||||
fi
|
||||
|
||||
# 生成 nginx 配置
|
||||
cat > /etc/nginx/http.d/default.conf << NGINX_EOF
|
||||
server {
|
||||
listen $PORT;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8081/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /health {
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
NGINX_EOF
|
||||
|
||||
# 启动后端(端口 8081)
|
||||
API_SERVER_PORT=8081 /app/nofx &
|
||||
sleep 2
|
||||
|
||||
# 启动 nginx(后台)
|
||||
nginx
|
||||
|
||||
echo "✅ NOFX started successfully"
|
||||
|
||||
# 保持容器运行
|
||||
tail -f /dev/null
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"nofx/store"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -83,7 +84,7 @@ func main() {
|
||||
filledOrders++
|
||||
|
||||
// 检查 filled_at
|
||||
if !order.FilledAt.IsZero() {
|
||||
if order.FilledAt > 0 {
|
||||
withFilledAt++
|
||||
} else {
|
||||
missingFilledAt++
|
||||
@@ -119,8 +120,8 @@ func main() {
|
||||
}
|
||||
|
||||
filledAtStr := "N/A"
|
||||
if !order.FilledAt.IsZero() {
|
||||
filledAtStr = order.FilledAt.Format("01-02 15:04")
|
||||
if order.FilledAt > 0 {
|
||||
filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
|
||||
}
|
||||
|
||||
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
||||
|
||||
168
scripts/test_lighter_orders.go
Normal file
168
scripts/test_lighter_orders.go
Normal file
@@ -0,0 +1,168 @@
|
||||
//go:build ignore
|
||||
|
||||
// Test script to verify Lighter API authentication
|
||||
// Run: go run scripts/test_lighter_orders.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Configuration - update these values
|
||||
walletAddr := os.Getenv("LIGHTER_WALLET")
|
||||
apiKeyPrivateKey := os.Getenv("LIGHTER_API_KEY")
|
||||
|
||||
if walletAddr == "" || apiKeyPrivateKey == "" {
|
||||
fmt.Println("Usage: LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go run scripts/test_lighter_orders.go")
|
||||
fmt.Println("Environment variables required:")
|
||||
fmt.Println(" LIGHTER_WALLET - Ethereum wallet address")
|
||||
fmt.Println(" LIGHTER_API_KEY - API key private key (40 bytes hex)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Lighter API Test ===")
|
||||
fmt.Printf("Wallet: %s\n\n", walletAddr)
|
||||
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(304)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
// Step 1: Get account info (no auth required)
|
||||
fmt.Println("1. Getting account info...")
|
||||
accountIndex, err := getAccountIndex(client, baseURL, walletAddr)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: account_index = %d\n\n", accountIndex)
|
||||
|
||||
// Step 2: Create TxClient and generate auth token
|
||||
fmt.Println("2. Creating TxClient and generating auth token...")
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
txClient, err := lighterClient.NewTxClient(httpClient, apiKeyPrivateKey, accountIndex, 0, chainID)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
authToken, err := txClient.GetAuthToken(time.Now().Add(1 * time.Hour))
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" OK: auth token generated\n\n")
|
||||
|
||||
// Step 3: Test GetActiveOrders with auth query parameter (NEW method)
|
||||
fmt.Println("3. Testing GetActiveOrders with auth query parameter (FIXED)...")
|
||||
encodedAuth := url.QueryEscape(authToken)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0&auth=%s",
|
||||
baseURL, accountIndex, encodedAuth)
|
||||
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(body, &result)
|
||||
|
||||
if code, ok := result["code"].(float64); ok && code == 200 {
|
||||
orders := result["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders\n", len(orders))
|
||||
if len(orders) > 0 {
|
||||
fmt.Println(" Sample orders:")
|
||||
for i, o := range orders {
|
||||
if i >= 3 {
|
||||
fmt.Printf(" ... and %d more\n", len(orders)-3)
|
||||
break
|
||||
}
|
||||
order := o.(map[string]interface{})
|
||||
fmt.Printf(" - ID: %v, Price: %v, Side: %v\n",
|
||||
order["order_id"], order["price"], order["is_ask"])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body))
|
||||
fmt.Println("\n Possible causes:")
|
||||
fmt.Println(" - API key not registered on-chain")
|
||||
fmt.Println(" - API key private key incorrect")
|
||||
fmt.Println(" - Account index mismatch")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Step 4: Test GetActiveOrders with Authorization header (OLD method - for comparison)
|
||||
fmt.Println("\n4. Testing GetActiveOrders with Authorization header (OLD method)...")
|
||||
endpoint2 := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=0",
|
||||
baseURL, accountIndex)
|
||||
|
||||
req, _ := http.NewRequest("GET", endpoint2, nil)
|
||||
req.Header.Set("Authorization", authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf(" FAILED: %v\n", err)
|
||||
} else {
|
||||
defer resp2.Body.Close()
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
var result2 map[string]interface{}
|
||||
json.Unmarshal(body2, &result2)
|
||||
|
||||
if code, ok := result2["code"].(float64); ok && code == 200 {
|
||||
orders := result2["orders"].([]interface{})
|
||||
fmt.Printf(" OK: Retrieved %d orders (both methods work!)\n", len(orders))
|
||||
} else {
|
||||
fmt.Printf(" FAILED: %s\n", string(body2))
|
||||
fmt.Println(" ^ This is expected - Authorization header doesn't work consistently")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n=== TEST COMPLETE ===")
|
||||
fmt.Println("If test 3 passed, the fix is working correctly.")
|
||||
}
|
||||
|
||||
func getAccountIndex(client *http.Client, baseURL, walletAddr string) (int64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", baseURL, walletAddr)
|
||||
resp, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Accounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"accounts"`
|
||||
SubAccounts []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
} `json:"sub_accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse: %w", err)
|
||||
}
|
||||
|
||||
if len(result.Accounts) > 0 {
|
||||
return result.Accounts[0].AccountIndex, nil
|
||||
}
|
||||
if len(result.SubAccounts) > 0 {
|
||||
return result.SubAccounts[0].AccountIndex, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("no account found")
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"enabled": enabled,
|
||||
"custom_api_url": customAPIURL,
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
|
||||
if apiKey != "" {
|
||||
@@ -167,7 +167,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"enabled": enabled,
|
||||
"custom_api_url": customAPIURL,
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
|
||||
@@ -147,7 +147,7 @@ func (BacktestCheckpoint) TableName() string {
|
||||
type BacktestEquity struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
|
||||
TS int64 `gorm:"column:ts;not null;index:idx_backtest_equity_run_ts"`
|
||||
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_equity_run_ts"`
|
||||
Equity float64 `gorm:"column:equity;not null"`
|
||||
Available float64 `gorm:"column:available;not null"`
|
||||
PnL float64 `gorm:"column:pnl;not null"`
|
||||
@@ -164,7 +164,7 @@ func (BacktestEquity) TableName() string {
|
||||
type BacktestTrade struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
|
||||
TS int64 `gorm:"column:ts;not null;index:idx_backtest_trades_run_ts"`
|
||||
TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_trades_run_ts"`
|
||||
Symbol string `gorm:"column:symbol;not null"`
|
||||
Action string `gorm:"column:action;not null"`
|
||||
Side string `gorm:"column:side;default:''"`
|
||||
@@ -217,7 +217,10 @@ func (s *BacktestStore) initTables() error {
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
|
||||
|
||||
if tableExists > 0 {
|
||||
// Tables exist - just ensure indexes exist
|
||||
// Tables exist - fix column types and ensure indexes exist
|
||||
// Fix ts column type from INTEGER to BIGINT (timestamps in milliseconds exceed int4 max)
|
||||
s.db.Exec(`ALTER TABLE backtest_equity ALTER COLUMN ts TYPE BIGINT`)
|
||||
s.db.Exec(`ALTER TABLE backtest_trades ALTER COLUMN ts TYPE BIGINT`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`)
|
||||
|
||||
@@ -236,7 +236,7 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
// Only update encrypted fields if not empty
|
||||
@@ -275,7 +275,7 @@ func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"account_name": accountName,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -21,6 +22,10 @@ func DB() *gorm.DB {
|
||||
func InitGorm(dbPath string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
|
||||
@@ -53,6 +58,10 @@ func InitGormPostgres(host string, port int, user, password, dbname, sslmode str
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().UTC()
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)
|
||||
|
||||
585
store/grid.go
Normal file
585
store/grid.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ==================== Grid Store Models ====================
|
||||
// These models mirror the grid package types but are defined here
|
||||
// to avoid import cycles between store and grid packages.
|
||||
|
||||
// GridConfigModel GORM model for grid_configs table
|
||||
type GridConfigModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
UserID string `json:"user_id" gorm:"index"`
|
||||
TraderID string `json:"trader_id" gorm:"index"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
GridCount int `json:"grid_count" gorm:"default:10"`
|
||||
TotalInvestment float64 `json:"total_investment" gorm:"not null"`
|
||||
Leverage int `json:"leverage" gorm:"default:5"`
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
UseATRBounds bool `json:"use_atr_bounds" gorm:"default:true"`
|
||||
ATRMultiplier float64 `json:"atr_multiplier" gorm:"default:2.0"`
|
||||
Distribution string `json:"distribution" gorm:"default:gaussian"`
|
||||
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct" gorm:"default:15.0"`
|
||||
StopLossPct float64 `json:"stop_loss_pct" gorm:"default:5.0"`
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct" gorm:"default:10"`
|
||||
MaxPositionSizePct float64 `json:"max_position_size_pct" gorm:"default:30"`
|
||||
|
||||
RegimeCheckInterval int `json:"regime_check_interval" gorm:"default:30"`
|
||||
AutoPauseOnTrend bool `json:"auto_pause_on_trend" gorm:"default:true"`
|
||||
MinRangingScore int `json:"min_ranging_score" gorm:"default:60"`
|
||||
TrendResumeThreshold int `json:"trend_resume_threshold" gorm:"default:70"`
|
||||
|
||||
// Box indicator periods (1h candles)
|
||||
ShortBoxPeriod int `json:"short_box_period" gorm:"default:72"` // 3 days
|
||||
MidBoxPeriod int `json:"mid_box_period" gorm:"default:240"` // 10 days
|
||||
LongBoxPeriod int `json:"long_box_period" gorm:"default:500"` // 21 days
|
||||
|
||||
// Effective leverage limits by regime level
|
||||
NarrowRegimeLeverage int `json:"narrow_regime_leverage" gorm:"default:2"`
|
||||
StandardRegimeLeverage int `json:"standard_regime_leverage" gorm:"default:4"`
|
||||
WideRegimeLeverage int `json:"wide_regime_leverage" gorm:"default:3"`
|
||||
VolatileRegimeLeverage int `json:"volatile_regime_leverage" gorm:"default:2"`
|
||||
|
||||
// Position limits by regime level (percentage of total investment)
|
||||
NarrowRegimePositionPct float64 `json:"narrow_regime_position_pct" gorm:"default:40"`
|
||||
StandardRegimePositionPct float64 `json:"standard_regime_position_pct" gorm:"default:70"`
|
||||
WideRegimePositionPct float64 `json:"wide_regime_position_pct" gorm:"default:60"`
|
||||
VolatileRegimePositionPct float64 `json:"volatile_regime_position_pct" gorm:"default:40"`
|
||||
|
||||
OrderRefreshSec int `json:"order_refresh_sec" gorm:"default:300"`
|
||||
UseMakerOnly bool `json:"use_maker_only" gorm:"default:true"`
|
||||
SlippageTolerPct float64 `json:"slippage_toler_pct" gorm:"default:0.1"`
|
||||
|
||||
AIProvider string `json:"ai_provider" gorm:"default:deepseek"`
|
||||
AIModel string `json:"ai_model" gorm:"default:deepseek-chat"`
|
||||
IsActive bool `json:"is_active" gorm:"default:false"`
|
||||
}
|
||||
|
||||
func (GridConfigModel) TableName() string {
|
||||
return "grid_configs"
|
||||
}
|
||||
|
||||
// GridInstanceModel GORM model for grid_instances table
|
||||
type GridInstanceModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
ConfigID string `json:"config_id" gorm:"index;not null"`
|
||||
Symbol string `json:"symbol" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
StoppedAt *time.Time `json:"stopped_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
CurrentUpperPrice float64 `json:"current_upper_price"`
|
||||
CurrentLowerPrice float64 `json:"current_lower_price"`
|
||||
CurrentGridSpacing float64 `json:"current_grid_spacing"`
|
||||
ActiveLevelCount int `json:"active_level_count"`
|
||||
CurrentRegime string `json:"current_regime"`
|
||||
RegimeScore int `json:"regime_score"`
|
||||
LastRegimeCheck time.Time `json:"last_regime_check"`
|
||||
ConsecutiveTrending int `json:"consecutive_trending"`
|
||||
|
||||
// Current regime level (narrow/standard/wide/volatile/trending)
|
||||
CurrentRegimeLevel string `json:"current_regime_level" gorm:"default:standard"`
|
||||
|
||||
// Box state
|
||||
ShortBoxUpper float64 `json:"short_box_upper"`
|
||||
ShortBoxLower float64 `json:"short_box_lower"`
|
||||
MidBoxUpper float64 `json:"mid_box_upper"`
|
||||
MidBoxLower float64 `json:"mid_box_lower"`
|
||||
LongBoxUpper float64 `json:"long_box_upper"`
|
||||
LongBoxLower float64 `json:"long_box_lower"`
|
||||
|
||||
// Breakout state
|
||||
BreakoutLevel string `json:"breakout_level" gorm:"default:none"` // none/short/mid/long
|
||||
BreakoutDirection string `json:"breakout_direction"` // up/down
|
||||
BreakoutConfirmCount int `json:"breakout_confirm_count" gorm:"default:0"`
|
||||
BreakoutStartTime time.Time `json:"breakout_start_time"`
|
||||
|
||||
// Position adjustment due to breakout
|
||||
PositionReductionPct float64 `json:"position_reduction_pct" gorm:"default:0"` // 0 = normal, 50 = reduced
|
||||
|
||||
TotalProfit float64 `json:"total_profit" gorm:"default:0"`
|
||||
TotalFees float64 `json:"total_fees" gorm:"default:0"`
|
||||
TotalTrades int `json:"total_trades" gorm:"default:0"`
|
||||
WinningTrades int `json:"winning_trades" gorm:"default:0"`
|
||||
MaxDrawdown float64 `json:"max_drawdown" gorm:"default:0"`
|
||||
CurrentDrawdown float64 `json:"current_drawdown" gorm:"default:0"`
|
||||
PeakEquity float64 `json:"peak_equity" gorm:"default:0"`
|
||||
DailyProfit float64 `json:"daily_profit" gorm:"default:0"`
|
||||
DailyLoss float64 `json:"daily_loss" gorm:"default:0"`
|
||||
LastDailyReset time.Time `json:"last_daily_reset"`
|
||||
}
|
||||
|
||||
func (GridInstanceModel) TableName() string {
|
||||
return "grid_instances"
|
||||
}
|
||||
|
||||
// GridLevelModel GORM model for grid_levels table
|
||||
type GridLevelModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelIndex int `json:"level_index" gorm:"not null"`
|
||||
Price float64 `json:"price" gorm:"not null"`
|
||||
State string `json:"state" gorm:"not null"`
|
||||
Side string `json:"side"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
OrderPrice float64 `json:"order_price,omitempty"`
|
||||
OrderQuantity float64 `json:"order_quantity,omitempty"`
|
||||
OrderCreatedAt *time.Time `json:"order_created_at,omitempty"`
|
||||
PositionSize float64 `json:"position_size,omitempty"`
|
||||
PositionEntry float64 `json:"position_entry,omitempty"`
|
||||
PositionOpenAt *time.Time `json:"position_open_at,omitempty"`
|
||||
AllocationWeight float64 `json:"allocation_weight"`
|
||||
AllocatedUSD float64 `json:"allocated_usd"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (GridLevelModel) TableName() string {
|
||||
return "grid_levels"
|
||||
}
|
||||
|
||||
// GridEventModel GORM model for grid_events table
|
||||
type GridEventModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
LevelID string `json:"level_id,omitempty" gorm:"index"`
|
||||
EventType string `json:"event_type" gorm:"not null"`
|
||||
EventTime time.Time `json:"event_time" gorm:"autoCreateTime"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Quantity float64 `json:"quantity,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
PnL float64 `json:"pnl,omitempty"`
|
||||
Fee float64 `json:"fee,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
OldRegime string `json:"old_regime,omitempty"`
|
||||
NewRegime string `json:"new_regime,omitempty"`
|
||||
TriggerType string `json:"trigger_type,omitempty"`
|
||||
RawData string `json:"raw_data,omitempty" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridEventModel) TableName() string {
|
||||
return "grid_events"
|
||||
}
|
||||
|
||||
// GridRegimeAssessmentModel GORM model for grid_regime_assessments table
|
||||
type GridRegimeAssessmentModel struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
InstanceID string `json:"instance_id" gorm:"index;not null"`
|
||||
AssessedAt time.Time `json:"assessed_at" gorm:"autoCreateTime"`
|
||||
Regime string `json:"regime" gorm:"not null"`
|
||||
Score int `json:"score" gorm:"not null"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BollingerSignal int `json:"bollinger_signal"`
|
||||
EMASignal int `json:"ema_signal"`
|
||||
MACDSignal int `json:"macd_signal"`
|
||||
VolumeSignal int `json:"volume_signal"`
|
||||
OISignal int `json:"oi_signal"`
|
||||
FundingSignal int `json:"funding_signal"`
|
||||
CandleSignal int `json:"candle_signal"`
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerWidth float64 `json:"bollinger_width"`
|
||||
EMADistance float64 `json:"ema_distance"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
AIReasoning string `json:"ai_reasoning" gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (GridRegimeAssessmentModel) TableName() string {
|
||||
return "grid_regime_assessments"
|
||||
}
|
||||
|
||||
// ==================== Grid Store ====================
|
||||
|
||||
// GridStore provides database operations for grid trading
|
||||
type GridStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewGridStore creates a new grid store
|
||||
func NewGridStore(db *gorm.DB) *GridStore {
|
||||
return &GridStore{db: db}
|
||||
}
|
||||
|
||||
// InitTables initializes grid-related tables
|
||||
func (s *GridStore) InitTables() error {
|
||||
// For PostgreSQL with existing tables, skip AutoMigrate to avoid type conflicts
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'grid_configs'`).Scan(&tableExists)
|
||||
|
||||
if tableExists > 0 {
|
||||
// Tables exist, just ensure indexes
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_user_id ON grid_configs(user_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_configs_trader_id ON grid_configs(trader_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_instances_config_id ON grid_instances(config_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_levels_instance_id ON grid_levels(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_instance_id ON grid_events(instance_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_events_level_id ON grid_events(level_id)`)
|
||||
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_grid_regime_assessments_instance_id ON grid_regime_assessments(instance_id)`)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AutoMigrate all grid tables
|
||||
if err := s.db.AutoMigrate(
|
||||
&GridConfigModel{},
|
||||
&GridInstanceModel{},
|
||||
&GridLevelModel{},
|
||||
&GridEventModel{},
|
||||
&GridRegimeAssessmentModel{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to migrate grid tables: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==================== Config Operations ====================
|
||||
|
||||
// SaveGridConfig saves or updates a grid configuration
|
||||
func (s *GridStore) SaveGridConfig(config *GridConfigModel) error {
|
||||
config.UpdatedAt = time.Now()
|
||||
if config.CreatedAt.IsZero() {
|
||||
config.CreatedAt = time.Now()
|
||||
}
|
||||
return s.db.Save(config).Error
|
||||
}
|
||||
|
||||
// LoadGridConfig loads a grid configuration by ID
|
||||
func (s *GridStore) LoadGridConfig(id string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("id = ?", id).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// LoadGridConfigByTrader loads a grid configuration by trader ID
|
||||
func (s *GridStore) LoadGridConfigByTrader(traderID string) (*GridConfigModel, error) {
|
||||
var config GridConfigModel
|
||||
err := s.db.Where("trader_id = ? AND is_active = true", traderID).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ListGridConfigs lists all grid configurations for a user
|
||||
func (s *GridStore) ListGridConfigs(userID string) ([]GridConfigModel, error) {
|
||||
var configs []GridConfigModel
|
||||
err := s.db.Where("user_id = ?", userID).Order("created_at DESC").Find(&configs).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
// DeleteGridConfig deletes a grid configuration and all related data
|
||||
func (s *GridStore) DeleteGridConfig(id string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Get all instances for this config
|
||||
var instances []GridInstanceModel
|
||||
if err := tx.Where("config_id = ?", id).Find(&instances).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete related data for each instance
|
||||
for _, instance := range instances {
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridLevelModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridEventModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("instance_id = ?", instance.ID).Delete(&GridRegimeAssessmentModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Delete instances
|
||||
if err := tx.Where("config_id = ?", id).Delete(&GridInstanceModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete config
|
||||
return tx.Where("id = ?", id).Delete(&GridConfigModel{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Instance Operations ====================
|
||||
|
||||
// SaveGridInstance saves or updates a grid instance
|
||||
func (s *GridStore) SaveGridInstance(instance *GridInstanceModel) error {
|
||||
instance.UpdatedAt = time.Now()
|
||||
return s.db.Save(instance).Error
|
||||
}
|
||||
|
||||
// LoadGridInstance loads a grid instance by config ID
|
||||
func (s *GridStore) LoadGridInstance(configID string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// LoadGridInstanceByID loads a grid instance by ID
|
||||
func (s *GridStore) LoadGridInstanceByID(id string) (*GridInstanceModel, error) {
|
||||
var instance GridInstanceModel
|
||||
err := s.db.Where("id = ?", id).First(&instance).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &instance, nil
|
||||
}
|
||||
|
||||
// ListGridInstances lists all instances for a config
|
||||
func (s *GridStore) ListGridInstances(configID string) ([]GridInstanceModel, error) {
|
||||
var instances []GridInstanceModel
|
||||
err := s.db.Where("config_id = ?", configID).
|
||||
Order("started_at DESC").
|
||||
Find(&instances).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// ==================== Level Operations ====================
|
||||
|
||||
// SaveGridLevel saves or updates a grid level
|
||||
func (s *GridStore) SaveGridLevel(level *GridLevelModel) error {
|
||||
level.UpdatedAt = time.Now()
|
||||
return s.db.Save(level).Error
|
||||
}
|
||||
|
||||
// SaveGridLevels saves multiple grid levels
|
||||
func (s *GridStore) SaveGridLevels(levels []GridLevelModel) error {
|
||||
if len(levels) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range levels {
|
||||
levels[i].UpdatedAt = now
|
||||
}
|
||||
return s.db.Save(&levels).Error
|
||||
}
|
||||
|
||||
// LoadGridLevels loads all levels for an instance
|
||||
func (s *GridStore) LoadGridLevels(instanceID string) ([]GridLevelModel, error) {
|
||||
var levels []GridLevelModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("level_index ASC").
|
||||
Find(&levels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return levels, nil
|
||||
}
|
||||
|
||||
// DeleteGridLevels deletes all levels for an instance
|
||||
func (s *GridStore) DeleteGridLevels(instanceID string) error {
|
||||
return s.db.Where("instance_id = ?", instanceID).Delete(&GridLevelModel{}).Error
|
||||
}
|
||||
|
||||
// ==================== Event Operations ====================
|
||||
|
||||
// SaveGridEvent saves a grid event
|
||||
func (s *GridStore) SaveGridEvent(event *GridEventModel) error {
|
||||
if event.EventTime.IsZero() {
|
||||
event.EventTime = time.Now()
|
||||
}
|
||||
return s.db.Create(event).Error
|
||||
}
|
||||
|
||||
// LoadRecentGridEvents loads recent events for an instance
|
||||
func (s *GridStore) LoadRecentGridEvents(instanceID string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// LoadGridEventsByType loads events of a specific type
|
||||
func (s *GridStore) LoadGridEventsByType(instanceID, eventType string, limit int) ([]GridEventModel, error) {
|
||||
var events []GridEventModel
|
||||
query := s.db.Where("instance_id = ? AND event_type = ?", instanceID, eventType).
|
||||
Order("event_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&events).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// CountGridEvents counts events for an instance
|
||||
func (s *GridStore) CountGridEvents(instanceID string) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ?", instanceID).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ==================== Regime Assessment Operations ====================
|
||||
|
||||
// SaveGridRegimeAssessment saves a regime assessment
|
||||
func (s *GridStore) SaveGridRegimeAssessment(assessment *GridRegimeAssessmentModel) error {
|
||||
if assessment.AssessedAt.IsZero() {
|
||||
assessment.AssessedAt = time.Now()
|
||||
}
|
||||
return s.db.Create(assessment).Error
|
||||
}
|
||||
|
||||
// LoadLatestGridRegime loads the latest regime assessment
|
||||
func (s *GridStore) LoadLatestGridRegime(instanceID string) (*GridRegimeAssessmentModel, error) {
|
||||
var assessment GridRegimeAssessmentModel
|
||||
err := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&assessment).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &assessment, nil
|
||||
}
|
||||
|
||||
// LoadGridRegimeHistory loads regime assessment history
|
||||
func (s *GridStore) LoadGridRegimeHistory(instanceID string, limit int) ([]GridRegimeAssessmentModel, error) {
|
||||
var assessments []GridRegimeAssessmentModel
|
||||
query := s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
err := query.Find(&assessments).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assessments, nil
|
||||
}
|
||||
|
||||
// ==================== Statistics Operations ====================
|
||||
|
||||
// GetGridInstanceStatistics returns statistics for an instance
|
||||
func (s *GridStore) GetGridInstanceStatistics(instanceID string) (map[string]interface{}, error) {
|
||||
var instance GridInstanceModel
|
||||
if err := s.db.Where("id = ?", instanceID).First(&instance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Count events by type
|
||||
var eventCounts []struct {
|
||||
EventType string
|
||||
Count int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("event_type, count(*) as count").
|
||||
Where("instance_id = ?", instanceID).
|
||||
Group("event_type").
|
||||
Find(&eventCounts)
|
||||
|
||||
eventCountMap := make(map[string]int64)
|
||||
for _, ec := range eventCounts {
|
||||
eventCountMap[ec.EventType] = ec.Count
|
||||
}
|
||||
|
||||
// Get latest regime
|
||||
var latestRegime GridRegimeAssessmentModel
|
||||
s.db.Where("instance_id = ?", instanceID).
|
||||
Order("assessed_at DESC").
|
||||
First(&latestRegime)
|
||||
|
||||
winRate := 0.0
|
||||
if instance.TotalTrades > 0 {
|
||||
winRate = float64(instance.WinningTrades) / float64(instance.TotalTrades) * 100
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"instance_id": instance.ID,
|
||||
"state": instance.State,
|
||||
"started_at": instance.StartedAt,
|
||||
"stopped_at": instance.StoppedAt,
|
||||
"total_profit": instance.TotalProfit,
|
||||
"total_fees": instance.TotalFees,
|
||||
"total_trades": instance.TotalTrades,
|
||||
"winning_trades": instance.WinningTrades,
|
||||
"win_rate": winRate,
|
||||
"max_drawdown": instance.MaxDrawdown,
|
||||
"current_drawdown": instance.CurrentDrawdown,
|
||||
"peak_equity": instance.PeakEquity,
|
||||
"active_level_count": instance.ActiveLevelCount,
|
||||
"current_regime": instance.CurrentRegime,
|
||||
"regime_score": instance.RegimeScore,
|
||||
"event_counts": eventCountMap,
|
||||
"latest_regime_score": latestRegime.Score,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGridPerformanceMetrics returns performance metrics for a time period
|
||||
func (s *GridStore) GetGridPerformanceMetrics(instanceID string, from, to time.Time) (map[string]interface{}, error) {
|
||||
// Count trades in period
|
||||
var tradeCounts struct {
|
||||
TotalFills int64
|
||||
BuyFills int64
|
||||
SellFills int64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("count(*) as total_fills, "+
|
||||
"sum(case when side = 'buy' then 1 else 0 end) as buy_fills, "+
|
||||
"sum(case when side = 'sell' then 1 else 0 end) as sell_fills").
|
||||
Where("instance_id = ? AND event_type = 'order_filled' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Scan(&tradeCounts)
|
||||
|
||||
// Sum profit/loss
|
||||
var pnlSum struct {
|
||||
TotalPnL float64
|
||||
TotalFee float64
|
||||
}
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Select("coalesce(sum(pnl), 0) as total_pnl, coalesce(sum(fee), 0) as total_fee").
|
||||
Where("instance_id = ? AND event_time BETWEEN ? AND ?", instanceID, from, to).
|
||||
Scan(&pnlSum)
|
||||
|
||||
// Count regime changes
|
||||
var regimeChanges int64
|
||||
s.db.Model(&GridEventModel{}).
|
||||
Where("instance_id = ? AND event_type = 'regime_change' AND event_time BETWEEN ? AND ?",
|
||||
instanceID, from, to).
|
||||
Count(®imeChanges)
|
||||
|
||||
return map[string]interface{}{
|
||||
"period_start": from,
|
||||
"period_end": to,
|
||||
"total_fills": tradeCounts.TotalFills,
|
||||
"buy_fills": tradeCounts.BuyFills,
|
||||
"sell_fills": tradeCounts.SellFills,
|
||||
"total_pnl": pnlSum.TotalPnL,
|
||||
"total_fees": pnlSum.TotalFee,
|
||||
"net_pnl": pnlSum.TotalPnL - pnlSum.TotalFee,
|
||||
"regime_changes": regimeChanges,
|
||||
}, nil
|
||||
}
|
||||
191
store/order.go
191
store/order.go
@@ -2,43 +2,44 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TraderOrder order record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderOrder struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
|
||||
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
|
||||
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||
Type string `gorm:"column:type;not null" json:"type"`
|
||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
|
||||
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
|
||||
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
|
||||
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
|
||||
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||
Type string `gorm:"column:type;not null" json:"type"`
|
||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
|
||||
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
|
||||
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name for TraderOrder
|
||||
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
|
||||
}
|
||||
|
||||
// TraderFill trade record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderFill struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
|
||||
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
|
||||
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
|
||||
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name for TraderFill
|
||||
@@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error {
|
||||
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col))
|
||||
}
|
||||
|
||||
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
|
||||
// Check if column is still timestamp type before migrating
|
||||
timestampColumns := []struct{ table, col string }{
|
||||
{"trader_orders", "created_at"},
|
||||
{"trader_orders", "updated_at"},
|
||||
{"trader_orders", "filled_at"},
|
||||
{"trader_fills", "created_at"},
|
||||
}
|
||||
for _, c := range timestampColumns {
|
||||
var dataType string
|
||||
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.col).Scan(&dataType)
|
||||
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
|
||||
// Convert timestamp to Unix milliseconds (bigint)
|
||||
s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col))
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure indexes exist
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`)
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`)
|
||||
@@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr
|
||||
"filled_quantity": filledQty,
|
||||
"avg_fill_price": avgPrice,
|
||||
"commission": commission,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
if status == "FILLED" {
|
||||
updates["filled_at"] = time.Now()
|
||||
updates["filled_at"] = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error
|
||||
@@ -217,6 +237,27 @@ func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetTraderOrdersFiltered gets trader's order list with optional symbol and status filters
|
||||
func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, status string, limit int) ([]*TraderOrder, error) {
|
||||
var orders []*TraderOrder
|
||||
query := s.db.Where("trader_id = ?", traderID)
|
||||
|
||||
if symbol != "" {
|
||||
query = query.Where("symbol = ?", symbol)
|
||||
}
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&orders).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query orders: %w", err)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetOrderFills gets order's fill records
|
||||
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
|
||||
var fills []*TraderFill
|
||||
@@ -324,29 +365,59 @@ func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
|
||||
|
||||
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
|
||||
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
|
||||
type symbolMaxID struct {
|
||||
Symbol string
|
||||
MaxTradeID int64
|
||||
type symbolTradeID struct {
|
||||
Symbol string
|
||||
ExchangeTradeID string
|
||||
}
|
||||
var results []symbolMaxID
|
||||
var results []symbolTradeID
|
||||
|
||||
// Query all trade IDs grouped by symbol, find max in Go to avoid database-specific CAST issues
|
||||
// (PostgreSQL INTEGER is 32-bit, can't handle Binance trade IDs > 2.1B)
|
||||
err := s.db.Model(&TraderFill{}).
|
||||
Select("symbol, MAX(CAST(exchange_trade_id AS INTEGER)) as max_trade_id").
|
||||
Select("symbol, exchange_trade_id").
|
||||
Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID).
|
||||
Group("symbol").
|
||||
Find(&results).Error
|
||||
if err != nil {
|
||||
// If CAST fails (non-numeric trade IDs), fallback to string comparison
|
||||
if strings.Contains(err.Error(), "CAST") || strings.Contains(err.Error(), "invalid") {
|
||||
return make(map[string]int64), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to query max trade IDs: %w", err)
|
||||
return nil, fmt.Errorf("failed to query trade IDs: %w", err)
|
||||
}
|
||||
|
||||
// Find max trade ID per symbol in Go (handles 64-bit integers properly)
|
||||
result := make(map[string]int64)
|
||||
for _, r := range results {
|
||||
result[r.Symbol] = r.MaxTradeID
|
||||
tradeID, err := strconv.ParseInt(r.ExchangeTradeID, 10, 64)
|
||||
if err != nil {
|
||||
continue // Skip non-numeric trade IDs
|
||||
}
|
||||
if tradeID > result[r.Symbol] {
|
||||
result[r.Symbol] = tradeID
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange
|
||||
// Used to recover sync state after service restart
|
||||
func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) {
|
||||
var fill TraderFill
|
||||
err := s.db.Where("exchange_id = ?", exchangeID).
|
||||
Order("created_at DESC").
|
||||
First(&fill).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fill.CreatedAt, nil
|
||||
}
|
||||
|
||||
// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms)
|
||||
func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) {
|
||||
var symbols []string
|
||||
err := s.db.Model(&TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs).
|
||||
Pluck("symbol", &symbols).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
@@ -25,30 +25,31 @@ type TraderStats struct {
|
||||
}
|
||||
|
||||
// TraderPosition position record
|
||||
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||
type TraderPosition struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
||||
EntryTime time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"`
|
||||
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
||||
ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"`
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||
Source string `gorm:"column:source;default:system" json:"source"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
|
||||
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||
Side string `gorm:"column:side;not null" json:"side"`
|
||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
||||
EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC
|
||||
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
||||
ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set
|
||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||
Source string `gorm:"column:source;default:system" json:"source"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name
|
||||
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
||||
if tableExists > 0 {
|
||||
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
|
||||
// Check if column is still timestamp type before migrating
|
||||
timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"}
|
||||
for _, col := range timestampColumns {
|
||||
var dataType string
|
||||
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType)
|
||||
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
|
||||
// Convert timestamp to Unix milliseconds (bigint)
|
||||
s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col))
|
||||
}
|
||||
}
|
||||
|
||||
// Just ensure index exists
|
||||
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`)
|
||||
return nil
|
||||
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
||||
|
||||
// ClosePosition closes position
|
||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
||||
now := time.Now()
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": now,
|
||||
"exit_time": nowMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -190,7 +204,8 @@ func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchang
|
||||
}
|
||||
|
||||
// ClosePositionFully marks position as fully closed
|
||||
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error {
|
||||
// exitTimeMs is Unix milliseconds UTC
|
||||
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error {
|
||||
var pos TraderPosition
|
||||
if err := s.db.First(&pos, id).Error; err != nil {
|
||||
return fmt.Errorf("failed to get position: %w", err)
|
||||
@@ -205,11 +220,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
|
||||
"quantity": quantity,
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTime,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": totalRealizedPnL,
|
||||
"fee": totalFee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -432,13 +448,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
EntryPrice: pos.EntryPrice,
|
||||
ExitPrice: pos.ExitPrice,
|
||||
RealizedPnL: pos.RealizedPnL,
|
||||
EntryTime: pos.EntryTime.Unix(),
|
||||
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
|
||||
}
|
||||
|
||||
if pos.ExitTime != nil {
|
||||
t.ExitTime = pos.ExitTime.Unix()
|
||||
duration := pos.ExitTime.Sub(pos.EntryTime)
|
||||
t.HoldDuration = formatDuration(duration)
|
||||
if pos.ExitTime > 0 {
|
||||
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
|
||||
durationMs := pos.ExitTime - pos.EntryTime
|
||||
t.HoldDuration = formatDurationMs(durationMs)
|
||||
}
|
||||
|
||||
if pos.EntryPrice > 0 {
|
||||
@@ -457,26 +473,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
||||
|
||||
// formatDuration formats a duration
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
return formatDurationMs(d.Milliseconds())
|
||||
}
|
||||
|
||||
// formatDurationMs formats a duration in milliseconds
|
||||
func formatDurationMs(ms int64) string {
|
||||
seconds := ms / 1000
|
||||
minutes := seconds / 60
|
||||
hours := minutes / 60
|
||||
days := hours / 24
|
||||
|
||||
if seconds < 60 {
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
if minutes < 60 {
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
if minutes == 0 {
|
||||
if hours < 24 {
|
||||
remainingMins := minutes % 60
|
||||
if remainingMins == 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
||||
return fmt.Sprintf("%dh%dm", hours, remainingMins)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
if hours == 0 {
|
||||
remainingHours := hours % 24
|
||||
if remainingHours == 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd%dh", days, hours)
|
||||
return fmt.Sprintf("%dd%dh", days, remainingHours)
|
||||
}
|
||||
|
||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||
@@ -566,8 +590,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
|
||||
s.WinTrades++
|
||||
}
|
||||
|
||||
if pos.ExitTime != nil {
|
||||
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
||||
if pos.ExitTime > 0 {
|
||||
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
||||
}
|
||||
}
|
||||
@@ -615,7 +639,7 @@ type HoldingTimeStats struct {
|
||||
// GetHoldingTimeStats analyzes performance by holding duration
|
||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||
var positions []TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
||||
}
|
||||
@@ -632,10 +656,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
|
||||
}
|
||||
|
||||
for _, pos := range positions {
|
||||
if pos.ExitTime == nil {
|
||||
if pos.ExitTime == 0 {
|
||||
continue
|
||||
}
|
||||
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours()
|
||||
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
|
||||
|
||||
var rangeKey string
|
||||
switch {
|
||||
@@ -792,12 +816,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
|
||||
|
||||
// Calculate average holding time
|
||||
var positions []TraderPosition
|
||||
s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions)
|
||||
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
|
||||
if len(positions) > 0 {
|
||||
var totalMins float64
|
||||
for _, pos := range positions {
|
||||
if pos.ExitTime != nil {
|
||||
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
||||
if pos.ExitTime > 0 {
|
||||
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||
}
|
||||
}
|
||||
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
||||
@@ -917,6 +941,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
|
||||
}
|
||||
|
||||
// ClosedPnLRecord represents a closed position record from exchange
|
||||
// All time fields use int64 millisecond timestamps (UTC)
|
||||
type ClosedPnLRecord struct {
|
||||
Symbol string
|
||||
Side string
|
||||
@@ -926,8 +951,8 @@ type ClosedPnLRecord struct {
|
||||
RealizedPnL float64
|
||||
Fee float64
|
||||
Leverage int
|
||||
EntryTime time.Time
|
||||
ExitTime time.Time
|
||||
EntryTime int64 // Unix milliseconds UTC
|
||||
ExitTime int64 // Unix milliseconds UTC
|
||||
OrderID string
|
||||
CloseType string
|
||||
ExchangeID string
|
||||
@@ -954,7 +979,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
|
||||
exchangePositionID := record.ExchangeID
|
||||
if exchangePositionID == "" {
|
||||
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL)
|
||||
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
|
||||
}
|
||||
|
||||
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
|
||||
@@ -965,19 +990,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
return false, nil
|
||||
}
|
||||
|
||||
exitTime := record.ExitTime
|
||||
entryTime := record.EntryTime
|
||||
exitTimeMs := record.ExitTime
|
||||
entryTimeMs := record.EntryTime
|
||||
|
||||
if exitTime.IsZero() || exitTime.Year() < 2000 {
|
||||
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
|
||||
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
|
||||
if exitTimeMs < minValidTime {
|
||||
return false, nil
|
||||
}
|
||||
if entryTime.IsZero() || entryTime.Year() < 2000 {
|
||||
entryTime = exitTime
|
||||
if entryTimeMs < minValidTime {
|
||||
entryTimeMs = exitTimeMs
|
||||
}
|
||||
if entryTime.After(exitTime) {
|
||||
entryTime = exitTime
|
||||
if entryTimeMs > exitTimeMs {
|
||||
entryTimeMs = exitTimeMs
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
pos := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -988,16 +1016,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
Quantity: record.Quantity,
|
||||
EntryQuantity: record.Quantity,
|
||||
EntryPrice: record.EntryPrice,
|
||||
EntryTime: entryTime,
|
||||
EntryTime: entryTimeMs,
|
||||
ExitPrice: record.ExitPrice,
|
||||
ExitOrderID: record.OrderID,
|
||||
ExitTime: &exitTime,
|
||||
ExitTime: exitTimeMs,
|
||||
RealizedPnL: record.RealizedPnL,
|
||||
Fee: record.Fee,
|
||||
Leverage: record.Leverage,
|
||||
Status: "CLOSED",
|
||||
CloseReason: record.CloseType,
|
||||
Source: "sync",
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
|
||||
err = s.db.Create(pos).Error
|
||||
@@ -1011,21 +1041,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetLastClosedPositionTime gets the most recent exit time
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
||||
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
|
||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
|
||||
var pos TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").
|
||||
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
|
||||
Order("exit_time DESC").
|
||||
First(&pos).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil {
|
||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
|
||||
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
|
||||
}
|
||||
|
||||
return *pos.ExitTime, nil
|
||||
return pos.ExitTime, nil
|
||||
}
|
||||
|
||||
// CreateOpenPosition creates an open position
|
||||
@@ -1076,15 +1106,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
}
|
||||
|
||||
// ClosePositionWithAccurateData closes a position with accurate data from exchange
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error {
|
||||
// exitTimeMs is Unix milliseconds UTC
|
||||
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error {
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTime,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
|
||||
}
|
||||
|
||||
// ProcessTrade processes a single trade and updates position accordingly
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) ProcessTrade(
|
||||
traderID, exchangeID, exchangeType, symbol, side, action string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
if strings.HasPrefix(action, "open_") {
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
|
||||
} else if strings.HasPrefix(action, "close_") {
|
||||
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
|
||||
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleOpen handles opening positions (create new or average into existing)
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) handleOpen(
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get existing OPEN position for (symbol, side)
|
||||
@@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen(
|
||||
return fmt.Errorf("failed to get open position: %w", err)
|
||||
}
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
if existing == nil {
|
||||
// Create new position
|
||||
position := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()),
|
||||
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
EntryPrice: price,
|
||||
EntryOrderID: orderID,
|
||||
EntryTime: tradeTime,
|
||||
EntryTime: tradeTimeMs,
|
||||
Leverage: 1,
|
||||
Status: "OPEN",
|
||||
Source: "sync",
|
||||
Fee: fee,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
return pb.positionStore.CreateOpenPosition(position)
|
||||
}
|
||||
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
|
||||
}
|
||||
|
||||
// handleClose handles closing positions (partial or full)
|
||||
// tradeTimeMs is Unix milliseconds UTC
|
||||
func (pb *PositionBuilder) handleClose(
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
tradeTimeMs int64,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get OPEN position
|
||||
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
|
||||
position.ID,
|
||||
finalExitPrice,
|
||||
orderID,
|
||||
tradeTime,
|
||||
tradeTimeMs,
|
||||
totalPnL,
|
||||
totalFee,
|
||||
"sync",
|
||||
|
||||
@@ -28,6 +28,7 @@ type Store struct {
|
||||
strategy *StrategyStore
|
||||
equity *EquityStore
|
||||
order *OrderStore
|
||||
grid *GridStore
|
||||
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -156,6 +157,9 @@ func (s *Store) initTables() error {
|
||||
if err := s.Order().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize order tables: %w", err)
|
||||
}
|
||||
if err := s.Grid().InitTables(); err != nil {
|
||||
return fmt.Errorf("failed to initialize grid tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -279,6 +283,16 @@ func (s *Store) Order() *OrderStore {
|
||||
return s.order
|
||||
}
|
||||
|
||||
// Grid gets grid trading storage
|
||||
func (s *Store) Grid() *GridStore {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.grid == nil {
|
||||
s.grid = NewGridStore(s.gdb)
|
||||
}
|
||||
return s.grid
|
||||
}
|
||||
|
||||
// Close closes database connection
|
||||
func (s *Store) Close() error {
|
||||
if s.driver != nil {
|
||||
|
||||
@@ -32,6 +32,9 @@ func (Strategy) TableName() string { return "strategies" }
|
||||
|
||||
// StrategyConfig strategy configuration details (JSON structure)
|
||||
type StrategyConfig struct {
|
||||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||||
StrategyType string `json:"strategy_type,omitempty"`
|
||||
|
||||
// language setting: "zh" for Chinese, "en" for English
|
||||
// This determines the language used for data formatting and prompt generation
|
||||
Language string `json:"language,omitempty"`
|
||||
@@ -45,6 +48,39 @@ type StrategyConfig struct {
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
// editable sections of System Prompt
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
|
||||
// Grid trading configuration (only used when StrategyType == "grid_trading")
|
||||
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
|
||||
}
|
||||
|
||||
// GridStrategyConfig grid trading specific configuration
|
||||
type GridStrategyConfig struct {
|
||||
// Trading pair (e.g., "BTCUSDT")
|
||||
Symbol string `json:"symbol"`
|
||||
// Number of grid levels (5-50)
|
||||
GridCount int `json:"grid_count"`
|
||||
// Total investment in USDT
|
||||
TotalInvestment float64 `json:"total_investment"`
|
||||
// Leverage (1-20)
|
||||
Leverage int `json:"leverage"`
|
||||
// Upper price boundary (0 = auto-calculate from ATR)
|
||||
UpperPrice float64 `json:"upper_price"`
|
||||
// Lower price boundary (0 = auto-calculate from ATR)
|
||||
LowerPrice float64 `json:"lower_price"`
|
||||
// Use ATR to auto-calculate bounds
|
||||
UseATRBounds bool `json:"use_atr_bounds"`
|
||||
// ATR multiplier for bound calculation (default 2.0)
|
||||
ATRMultiplier float64 `json:"atr_multiplier"`
|
||||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||||
Distribution string `json:"distribution"`
|
||||
// Maximum drawdown percentage before emergency exit
|
||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"`
|
||||
// Stop loss percentage per position
|
||||
StopLossPct float64 `json:"stop_loss_pct"`
|
||||
// Daily loss limit percentage
|
||||
DailyLossLimitPct float64 `json:"daily_loss_limit_pct"`
|
||||
// Use maker-only orders for lower fees
|
||||
UseMakerOnly bool `json:"use_maker_only"`
|
||||
}
|
||||
|
||||
// PromptSectionsConfig editable sections of System Prompt
|
||||
@@ -328,7 +364,7 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
|
||||
"config": strategy.Config,
|
||||
"is_public": strategy.IsPublic,
|
||||
"config_visible": strategy.ConfigVisible,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -248,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByExchangeID gets traders that use a specific exchange
|
||||
func (s *TraderStore) ListByExchangeID(userID, exchangeID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND exchange_id = ?", userID, exchangeID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByAIModelID gets traders that use a specific AI model
|
||||
func (s *TraderStore) ListByAIModelID(userID, aiModelID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND ai_model_id = ?", userID, aiModelID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
|
||||
func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
|
||||
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"updated_at": time.Now(),
|
||||
"updated_at": time.Now().UTC(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -68,7 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -85,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -96,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -113,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -125,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -1407,10 +1407,201 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
body, err := t.request("GET", "/fapi/v3/openOrders", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrderID int64 `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"positionSide"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
StopPrice string `json:"stopPrice"`
|
||||
OrigQty string `json:"origQty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
PositionSide: order.PositionSide,
|
||||
Type: order.Type,
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Get precision information
|
||||
prec, err := t.getPrecision(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get precision: %w", err)
|
||||
}
|
||||
|
||||
// Convert to string with correct precision format
|
||||
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
|
||||
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
|
||||
|
||||
// Determine side
|
||||
side := "BUY"
|
||||
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"symbol": req.Symbol,
|
||||
"positionSide": "BOTH",
|
||||
"type": "LIMIT",
|
||||
"side": side,
|
||||
"timeInForce": "GTC",
|
||||
"quantity": qtyStr,
|
||||
"price": priceStr,
|
||||
}
|
||||
|
||||
// Add reduceOnly if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = "true"
|
||||
}
|
||||
|
||||
body, err := t.request("POST", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID
|
||||
orderID := ""
|
||||
if id, ok := result["orderId"].(float64); ok {
|
||||
orderID = fmt.Sprintf("%.0f", id)
|
||||
} else if id, ok := result["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
|
||||
// Extract client order ID
|
||||
clientOrderID := ""
|
||||
if cid, ok := result["clientOrderId"].(string); ok {
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
Side: side,
|
||||
Price: formattedPrice,
|
||||
Quantity: formattedQty,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by order ID
|
||||
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 20
|
||||
}
|
||||
|
||||
// Aster uses public endpoint (no signature required)
|
||||
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"` // [[price, qty], ...]
|
||||
Asks [][]string `json:"asks"` // [[price, qty], ...]
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert string arrays to float64 arrays
|
||||
bids = make([][]float64, len(result.Bids))
|
||||
for i, bid := range result.Bids {
|
||||
if len(bid) >= 2 {
|
||||
price, _ := strconv.ParseFloat(bid[0], 64)
|
||||
qty, _ := strconv.ParseFloat(bid[1], 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
asks = make([][]float64, len(result.Asks))
|
||||
for i, ask := range result.Asks {
|
||||
if len(ask) >= 2 {
|
||||
price, _ := strconv.ParseFloat(ask[0], 64)
|
||||
qty, _ := strconv.ParseFloat(ask[1], 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ type AutoTrader struct {
|
||||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||||
lastBalanceSyncTime time.Time // Last balance sync time
|
||||
userID string // User ID
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -419,9 +420,25 @@ func (at *AutoTrader) Run() error {
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Check if this is a grid trading strategy
|
||||
isGridStrategy := at.IsGridStrategy()
|
||||
if isGridStrategy {
|
||||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||||
if err := at.InitializeGrid(); err != nil {
|
||||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||||
return fmt.Errorf("grid initialization failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately on first run
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -435,8 +452,14 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
case <-at.stopMonitorCh:
|
||||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
||||
@@ -637,7 +660,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
TakeProfit: d.TakeProfit,
|
||||
Confidence: d.Confidence,
|
||||
Reasoning: d.Reasoning,
|
||||
Timestamp: time.Now(),
|
||||
Timestamp: time.Now().UTC(),
|
||||
Success: false,
|
||||
}
|
||||
|
||||
@@ -744,8 +767,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
// Priority 1: Get from database (trader_positions table) - most accurate
|
||||
if at.store != nil {
|
||||
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||||
if !dbPos.EntryTime.IsZero() {
|
||||
updateTime = dbPos.EntryTime.UnixMilli()
|
||||
if dbPos.EntryTime > 0 {
|
||||
updateTime = dbPos.EntryTime
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1365,6 +1388,12 @@ func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
}
|
||||
|
||||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||||
// This is used by grid trading and other components that need direct exchange access
|
||||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||||
return at.trader
|
||||
}
|
||||
|
||||
// GetName gets trader name
|
||||
func (at *AutoTrader) GetName() string {
|
||||
return at.name
|
||||
@@ -1471,7 +1500,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
isRunning := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"trader_id": at.id,
|
||||
"trader_name": at.name,
|
||||
"ai_model": at.aiModel,
|
||||
@@ -1486,6 +1515,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||||
"ai_provider": aiProvider,
|
||||
}
|
||||
|
||||
// Add strategy info
|
||||
if at.config.StrategyConfig != nil {
|
||||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||||
if at.config.StrategyConfig.GridConfig != nil {
|
||||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountInfo gets account information (for API)
|
||||
@@ -1967,6 +2006,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
switch action {
|
||||
case "open_long", "open_short":
|
||||
// Open position: create new position record
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
pos := &store.TraderPosition{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchangeID, // Exchange account UUID
|
||||
@@ -1976,9 +2016,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
Quantity: quantity,
|
||||
EntryPrice: price,
|
||||
EntryOrderID: orderID,
|
||||
EntryTime: time.Now(),
|
||||
EntryTime: nowMs,
|
||||
Leverage: leverage,
|
||||
Status: "OPEN",
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
if err := at.store.Position().Create(pos); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
||||
@@ -1996,7 +2038,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
at.id, at.exchangeID, at.exchange,
|
||||
symbol, side, action,
|
||||
quantity, price, fee, 0, // realizedPnL will be calculated
|
||||
time.Now(), orderID,
|
||||
time.Now().UTC().UnixMilli(), orderID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||||
} else {
|
||||
@@ -2049,8 +2091,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
|
||||
ReduceOnly: reduceOnly,
|
||||
ClosePosition: reduceOnly,
|
||||
OrderAction: orderAction,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2091,7 +2133,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0, // Will be calculated for close orders
|
||||
IsMaker: false, // Market orders are usually taker
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
|
||||
// Calculate realized PnL for close orders
|
||||
@@ -2215,3 +2257,8 @@ func getSideFromAction(action string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GetOpenOrders returns open orders (pending SL/TP) from exchange
|
||||
func (at *AutoTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
return at.trader.GetOpenOrders(symbol)
|
||||
}
|
||||
|
||||
|
||||
1579
trader/auto_trader_grid.go
Normal file
1579
trader/auto_trader_grid.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -716,6 +716,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price to correct precision
|
||||
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side and position side
|
||||
var side futures.SideType
|
||||
var positionSide futures.PositionSideType
|
||||
|
||||
if req.Side == "BUY" {
|
||||
side = futures.SideTypeBuy
|
||||
positionSide = futures.PositionSideTypeLong
|
||||
} else {
|
||||
side = futures.SideTypeSell
|
||||
positionSide = futures.PositionSideTypeShort
|
||||
}
|
||||
|
||||
// Build order service with broker ID
|
||||
orderService := t.client.NewCreateOrderService().
|
||||
Symbol(req.Symbol).
|
||||
Side(side).
|
||||
PositionSide(positionSide).
|
||||
Type(futures.OrderTypeLimit).
|
||||
TimeInForce(futures.TimeInForceTypeGTC).
|
||||
Quantity(quantityStr).
|
||||
Price(priceStr).
|
||||
NewClientOrderID(getBrOrderID())
|
||||
|
||||
// Execute order
|
||||
order, err := orderService.Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: string(order.Status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
|
||||
// Parse order ID to int64
|
||||
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.client.NewCancelOrderService().
|
||||
Symbol(symbol).
|
||||
OrderID(orderIDInt).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
book, err := t.client.NewDepthService().
|
||||
Symbol(symbol).
|
||||
Limit(depth).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert bids
|
||||
bids = make([][]float64, len(book.Bids))
|
||||
for i, bid := range book.Bids {
|
||||
price, _ := strconv.ParseFloat(bid.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
// Convert asks
|
||||
asks = make([][]float64, len(book.Asks))
|
||||
for i, ask := range book.Asks {
|
||||
price, _ := strconv.ParseFloat(ask.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
|
||||
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
@@ -776,6 +895,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get legacy open orders
|
||||
orders, err := t.client.NewListOpenOrdersService().
|
||||
Symbol(symbol).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Type: string(order.Type),
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: string(order.Status),
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Get Algo orders (new API for stop-loss/take-profit)
|
||||
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
|
||||
Symbol(symbol).
|
||||
Do(context.Background())
|
||||
|
||||
if err == nil {
|
||||
for _, algoOrder := range algoOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
|
||||
Symbol: algoOrder.Symbol,
|
||||
Side: string(algoOrder.Side),
|
||||
PositionSide: string(algoOrder.PositionSide),
|
||||
Type: string(algoOrder.OrderType),
|
||||
Price: 0, // Algo orders use stop price
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets market price
|
||||
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
|
||||
@@ -977,6 +1154,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
|
||||
return fmt.Sprintf(format, quantity), nil
|
||||
}
|
||||
|
||||
// GetSymbolPricePrecision gets the price precision for a trading pair
|
||||
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
|
||||
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get trading rules: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range exchangeInfo.Symbols {
|
||||
if s.Symbol == symbol {
|
||||
// Get precision from PRICE_FILTER filter
|
||||
for _, filter := range s.Filters {
|
||||
if filter["filterType"] == "PRICE_FILTER" {
|
||||
tickSize := filter["tickSize"].(string)
|
||||
precision := calculatePrecision(tickSize)
|
||||
return precision, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2 decimal places for price
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
// FormatPrice formats price to correct precision
|
||||
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
|
||||
precision, err := t.GetSymbolPricePrecision(symbol)
|
||||
if err != nil {
|
||||
// If retrieval fails, use default format
|
||||
return fmt.Sprintf("%.2f", price), nil
|
||||
}
|
||||
|
||||
format := fmt.Sprintf("%%.%df", precision)
|
||||
return fmt.Sprintf(format, price), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && stringContains(s, substr)
|
||||
@@ -1122,7 +1335,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
|
||||
TradeID: strconv.FormatInt(income.TranID, 10),
|
||||
Symbol: income.Symbol,
|
||||
RealizedPnL: pnl,
|
||||
Time: time.UnixMilli(income.Time),
|
||||
Time: time.UnixMilli(income.Time).UTC(),
|
||||
// Note: Income API doesn't provide price, quantity, side, fee
|
||||
// For accurate data, use GetTradesForSymbol with specific symbol
|
||||
}
|
||||
@@ -1167,7 +1380,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -1210,7 +1423,7 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
Time: time.UnixMilli(at.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -1244,3 +1457,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
|
||||
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
|
||||
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
|
||||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||
IncomeType("REALIZED_PNL").
|
||||
StartTime(lastSyncTime.UnixMilli()).
|
||||
Limit(1000).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get PnL history: %w", err)
|
||||
}
|
||||
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, income := range incomes {
|
||||
if income.Symbol != "" {
|
||||
symbolMap[income.Symbol] = true
|
||||
}
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for symbol := range symbolMap {
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// syncState stores the last sync time for incremental sync
|
||||
// syncState stores the last sync time (Unix ms) for incremental sync
|
||||
var (
|
||||
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime
|
||||
binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
|
||||
binanceSyncStateMutex sync.RWMutex
|
||||
)
|
||||
|
||||
@@ -25,42 +25,106 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get last sync time (default to 24 hours ago for first sync)
|
||||
orderStore := st.Order()
|
||||
|
||||
// Get last sync time (Unix ms) - first try memory, then database, then default
|
||||
binanceSyncStateMutex.RLock()
|
||||
lastSyncTime, exists := binanceSyncState[exchangeID]
|
||||
lastSyncTimeMs, exists := binanceSyncState[exchangeID]
|
||||
binanceSyncStateMutex.RUnlock()
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
if !exists {
|
||||
lastSyncTime = time.Now().Add(-24 * time.Hour)
|
||||
// Try to get last fill time from database (persist across restarts)
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err == nil && lastFillTimeMs > 0 {
|
||||
// If recovered time is in the future, it's clearly wrong - use default
|
||||
if lastFillTimeMs > nowMs {
|
||||
logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default",
|
||||
lastFillTimeMs, nowMs)
|
||||
lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago
|
||||
} else {
|
||||
// Add 1 second buffer to avoid re-fetching the same fill
|
||||
lastSyncTimeMs = lastFillTimeMs + 1000
|
||||
logger.Infof("📅 Recovered last sync time from DB: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
} else {
|
||||
// First sync: go back 24 hours
|
||||
lastSyncTimeMs = nowMs - 24*60*60*1000
|
||||
logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||
syncStartTime := time.Now()
|
||||
syncStartTimeMs := nowMs
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
|
||||
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||
|
||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||
orderStore := st.Order()
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
||||
maxTradeIDs = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call)
|
||||
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||
// Step 2: Detect symbols to sync using multiple methods
|
||||
// COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades)
|
||||
symbolMap := make(map[string]bool)
|
||||
lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls
|
||||
|
||||
// Method 1: COMMISSION income detection
|
||||
commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err)
|
||||
// Fallback: only sync symbols with active positions
|
||||
changedSymbols = t.getPositionSymbols()
|
||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Always include active positions (catches trades that COMMISSION missed)
|
||||
positionSymbols := t.getPositionSymbols()
|
||||
logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols)
|
||||
for _, s := range positionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 3: Include symbols from recent fills in DB (in case some were partially synced)
|
||||
recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs)
|
||||
logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols)
|
||||
for _, s := range recentSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
|
||||
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||
if len(symbolMap) == 0 {
|
||||
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
|
||||
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var changedSymbols []string
|
||||
for s := range symbolMap {
|
||||
changedSymbols = append(changedSymbols, s)
|
||||
}
|
||||
|
||||
if len(changedSymbols) == 0 {
|
||||
logger.Infof("📭 No symbols with new trades to sync")
|
||||
// Update last sync time even if no changes
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -98,7 +162,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||
if len(failedSymbols) == 0 {
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||
binanceSyncStateMutex.Unlock()
|
||||
} else {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
@@ -110,7 +174,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(allTrades, func(i, j int) bool {
|
||||
return allTrades[i].Time.Before(allTrades[j].Time)
|
||||
return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one
|
||||
@@ -145,7 +209,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
// Normalize side
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -162,9 +227,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -173,7 +238,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
@@ -190,7 +255,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -202,7 +267,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
@@ -210,8 +275,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)",
|
||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction,
|
||||
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||
}
|
||||
|
||||
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||
@@ -278,6 +344,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
|
||||
|
||||
// StartOrderSync starts background order sync task for Binance
|
||||
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
// Run first sync immediately
|
||||
go func() {
|
||||
logger.Infof("🔄 Running initial Binance order sync...")
|
||||
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Then run periodically
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
|
||||
461
trader/binance_order_sync_test.go
Normal file
461
trader/binance_order_sync_test.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func skipIfNoLiveTest(t *testing.T) {
|
||||
if os.Getenv("BINANCE_LIVE_TEST") != "1" {
|
||||
t.Skip("Skipping live test. Set BINANCE_LIVE_TEST=1 to run")
|
||||
}
|
||||
}
|
||||
|
||||
func getBinanceTestCredentials(t *testing.T) (string, string) {
|
||||
apiKey := os.Getenv("BINANCE_TEST_API_KEY")
|
||||
secretKey := os.Getenv("BINANCE_TEST_SECRET_KEY")
|
||||
if apiKey == "" || secretKey == "" {
|
||||
t.Skip("Skipping test. Set BINANCE_TEST_API_KEY and BINANCE_TEST_SECRET_KEY env vars")
|
||||
}
|
||||
return apiKey, secretKey
|
||||
}
|
||||
|
||||
func createBinanceTestTrader(t *testing.T) *FuturesTrader {
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestBinanceConnection tests basic API connectivity
|
||||
func TestBinanceConnection(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get balance: %v", err)
|
||||
}
|
||||
t.Logf("✅ Connection OK - Balance: %v", balance)
|
||||
}
|
||||
|
||||
// TestBinanceGetPositions tests position retrieval
|
||||
func TestBinanceGetPositions(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📊 Found %d positions with non-zero amount:", len(positions))
|
||||
for i, pos := range positions {
|
||||
symbol := pos["symbol"].(string)
|
||||
side := pos["side"].(string)
|
||||
posAmt := pos["positionAmt"].(float64)
|
||||
entryPrice := pos["entryPrice"].(float64)
|
||||
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||||
|
||||
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f pnl=%.4f",
|
||||
i+1, symbol, side, posAmt, entryPrice, unrealizedPnl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetCommissionSymbols tests COMMISSION income detection
|
||||
func TestBinanceGetCommissionSymbols(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// Test different time ranges
|
||||
timeRanges := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
}{
|
||||
{"1 hour", 1 * time.Hour},
|
||||
{"24 hours", 24 * time.Hour},
|
||||
{"7 days", 7 * 24 * time.Hour},
|
||||
{"30 days", 30 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tr := range timeRanges {
|
||||
startTime := time.Now().Add(-tr.duration)
|
||||
symbols, err := trader.GetCommissionSymbols(startTime)
|
||||
if err != nil {
|
||||
t.Logf("❌ %s: Failed to get commission symbols: %v", tr.name, err)
|
||||
continue
|
||||
}
|
||||
t.Logf("📋 %s: COMMISSION symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetPnLSymbols tests REALIZED_PNL income detection
|
||||
func TestBinanceGetPnLSymbols(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
timeRanges := []struct {
|
||||
name string
|
||||
duration time.Duration
|
||||
}{
|
||||
{"1 hour", 1 * time.Hour},
|
||||
{"24 hours", 24 * time.Hour},
|
||||
{"7 days", 7 * 24 * time.Hour},
|
||||
{"30 days", 30 * 24 * time.Hour},
|
||||
}
|
||||
|
||||
for _, tr := range timeRanges {
|
||||
startTime := time.Now().Add(-tr.duration)
|
||||
symbols, err := trader.GetPnLSymbols(startTime)
|
||||
if err != nil {
|
||||
t.Logf("❌ %s: Failed to get PnL symbols: %v", tr.name, err)
|
||||
continue
|
||||
}
|
||||
t.Logf("📋 %s: REALIZED_PNL symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetAllIncomeTypes tests all income types to understand data availability
|
||||
func TestBinanceGetAllIncomeTypes(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// All possible income types from Binance API
|
||||
incomeTypes := []string{
|
||||
"TRANSFER",
|
||||
"WELCOME_BONUS",
|
||||
"REALIZED_PNL",
|
||||
"FUNDING_FEE",
|
||||
"COMMISSION",
|
||||
"INSURANCE_CLEAR",
|
||||
"REFERRAL_KICKBACK",
|
||||
"COMMISSION_REBATE",
|
||||
"API_REBATE",
|
||||
"CONTEST_REWARD",
|
||||
"CROSS_COLLATERAL_TRANSFER",
|
||||
"OPTIONS_PREMIUM_FEE",
|
||||
"OPTIONS_SETTLE_PROFIT",
|
||||
"INTERNAL_TRANSFER",
|
||||
"AUTO_EXCHANGE",
|
||||
"DELIVERED_SETTELMENT",
|
||||
"COIN_SWAP_DEPOSIT",
|
||||
"COIN_SWAP_WITHDRAW",
|
||||
"POSITION_LIMIT_INCREASE_FEE",
|
||||
}
|
||||
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
t.Logf("🔍 Checking all income types from %s:", startTime.Format(time.RFC3339))
|
||||
|
||||
for _, incomeType := range incomeTypes {
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
IncomeType(incomeType).
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", incomeType, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(incomes) > 0 {
|
||||
symbolMap := make(map[string]int)
|
||||
for _, inc := range incomes {
|
||||
if inc.Symbol != "" {
|
||||
symbolMap[inc.Symbol]++
|
||||
}
|
||||
}
|
||||
t.Logf(" ✅ %s: %d records, symbols: %v", incomeType, len(incomes), symbolMap)
|
||||
} else {
|
||||
t.Logf(" ⚪ %s: 0 records", incomeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceGetTradesForSymbol tests trade retrieval for specific symbols
|
||||
func TestBinanceGetTradesForSymbol(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// Common trading pairs
|
||||
symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"}
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
t.Logf("🔍 Checking trades for common symbols from %s:", startTime.Format(time.RFC3339))
|
||||
|
||||
for _, symbol := range symbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(trades) > 0 {
|
||||
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||
// Print first and last trade
|
||||
first := trades[0]
|
||||
last := trades[len(trades)-1]
|
||||
t.Logf(" First: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||
first.TradeID, first.Symbol, first.Side,
|
||||
first.Quantity, first.Price, first.RealizedPnL,
|
||||
first.Time.Format(time.RFC3339))
|
||||
if len(trades) > 1 {
|
||||
t.Logf(" Last: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||
last.TradeID, last.Symbol, last.Side,
|
||||
last.Quantity, last.Price, last.RealizedPnL,
|
||||
last.Time.Format(time.RFC3339))
|
||||
}
|
||||
} else {
|
||||
t.Logf(" ⚪ %s: 0 trades", symbol)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceTimestampFormats tests different timestamp formats
|
||||
func TestBinanceTimestampFormats(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
now := time.Now()
|
||||
nowUTC := time.Now().UTC()
|
||||
|
||||
t.Logf("🕐 Time comparison:")
|
||||
t.Logf(" time.Now(): %s (UnixMilli: %d)", now.Format(time.RFC3339), now.UnixMilli())
|
||||
t.Logf(" time.Now().UTC(): %s (UnixMilli: %d)", nowUTC.Format(time.RFC3339), nowUTC.UnixMilli())
|
||||
t.Logf(" Difference: %v", now.Sub(nowUTC))
|
||||
|
||||
// The key insight: UnixMilli() should be the SAME regardless of timezone
|
||||
if now.UnixMilli() != nowUTC.UnixMilli() {
|
||||
t.Errorf("❌ UnixMilli() differs between local and UTC! This should never happen.")
|
||||
} else {
|
||||
t.Logf(" ✅ UnixMilli() is the same (correct behavior)")
|
||||
}
|
||||
|
||||
// Test what happens when we parse a time stored in DB
|
||||
// Simulate old DB value stored in local time
|
||||
oldLocalTime := time.Date(2026, 1, 6, 18, 0, 0, 0, time.Local) // 18:00 local
|
||||
oldLocalTimeAsUTC := time.Date(2026, 1, 6, 18, 0, 0, 0, time.UTC) // Same numbers but UTC
|
||||
|
||||
t.Logf("\n🔍 Timezone mismatch scenario:")
|
||||
t.Logf(" Old DB time (local): %s (UnixMilli: %d)", oldLocalTime.Format(time.RFC3339), oldLocalTime.UnixMilli())
|
||||
t.Logf(" Same time parsed as UTC: %s (UnixMilli: %d)", oldLocalTimeAsUTC.Format(time.RFC3339), oldLocalTimeAsUTC.UnixMilli())
|
||||
t.Logf(" Difference: %v", time.Duration(oldLocalTimeAsUTC.UnixMilli()-oldLocalTime.UnixMilli())*time.Millisecond)
|
||||
|
||||
// If server is in +8 timezone, the difference should be 8 hours
|
||||
_, offset := now.Zone()
|
||||
t.Logf(" Local timezone offset: %d seconds (%d hours)", offset, offset/3600)
|
||||
}
|
||||
|
||||
// TestBinanceFullSyncSimulation simulates the full sync process
|
||||
func TestBinanceFullSyncSimulation(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
t.Logf("🔄 Simulating full sync process...")
|
||||
|
||||
// Step 1: Determine lastSyncTime (simulating first run)
|
||||
lastSyncTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
t.Logf("\n📅 Step 1: lastSyncTime = %s", lastSyncTime.Format(time.RFC3339))
|
||||
|
||||
// Step 2: Detect symbols using all methods
|
||||
symbolMap := make(map[string]bool)
|
||||
|
||||
// Method 1: COMMISSION
|
||||
commissionSymbols, err := trader.GetCommissionSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ COMMISSION failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" 📋 COMMISSION symbols: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Positions
|
||||
positions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetPositions failed: %v", err)
|
||||
} else {
|
||||
var posSymbols []string
|
||||
for _, pos := range positions {
|
||||
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
|
||||
posSymbols = append(posSymbols, symbol)
|
||||
symbolMap[symbol] = true
|
||||
}
|
||||
}
|
||||
t.Logf(" 📋 Position symbols: %d - %v", len(posSymbols), posSymbols)
|
||||
}
|
||||
|
||||
// Method 3: REALIZED_PNL (fallback)
|
||||
pnlSymbols, err := trader.GetPnLSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ REALIZED_PNL failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" 📋 REALIZED_PNL symbols: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all symbols
|
||||
var allSymbols []string
|
||||
for s := range symbolMap {
|
||||
allSymbols = append(allSymbols, s)
|
||||
}
|
||||
t.Logf("\n📊 Step 2: Total unique symbols to sync: %d - %v", len(allSymbols), allSymbols)
|
||||
|
||||
if len(allSymbols) == 0 {
|
||||
t.Logf("❌ No symbols found! This is the bug - nothing to sync")
|
||||
t.Logf("\n🔍 Investigating why no symbols found...")
|
||||
|
||||
// Try to query all income (without type filter) to see if there's ANY activity
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(lastSyncTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Logf(" Failed to get all income: %v", err)
|
||||
} else {
|
||||
t.Logf(" All income records (no type filter): %d", len(incomes))
|
||||
typeCount := make(map[string]int)
|
||||
for _, inc := range incomes {
|
||||
typeCount[inc.IncomeType]++
|
||||
}
|
||||
t.Logf(" Income types breakdown: %v", typeCount)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Query trades for each symbol
|
||||
t.Logf("\n📥 Step 3: Querying trades for each symbol...")
|
||||
totalTrades := 0
|
||||
for _, symbol := range allSymbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, lastSyncTime, 500)
|
||||
if err != nil {
|
||||
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
totalTrades += len(trades)
|
||||
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||
|
||||
// Print sample trades
|
||||
for i, trade := range trades {
|
||||
if i >= 3 {
|
||||
t.Logf(" ... and %d more trades", len(trades)-3)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f fee=%.6f time=%s",
|
||||
i+1, trade.TradeID, trade.Symbol, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee,
|
||||
trade.Time.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n✅ Sync simulation complete: %d total trades found across %d symbols",
|
||||
totalTrades, len(allSymbols))
|
||||
}
|
||||
|
||||
// TestBinanceTradeIDRange tests trade ID ranges to understand the data
|
||||
func TestBinanceTradeIDRange(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
// First find symbols with trades
|
||||
startTime := time.Now().Add(-30 * 24 * time.Hour)
|
||||
commissionSymbols, _ := trader.GetCommissionSymbols(startTime)
|
||||
pnlSymbols, _ := trader.GetPnLSymbols(startTime)
|
||||
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, s := range commissionSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
for _, s := range pnlSymbols {
|
||||
symbolMap[s] = true
|
||||
}
|
||||
|
||||
if len(symbolMap) == 0 {
|
||||
t.Log("No symbols with activity found")
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("🔍 Checking trade ID ranges for symbols with activity:")
|
||||
|
||||
for symbol := range symbolMap {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||
if err != nil || len(trades) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var minID, maxID int64 = 1<<62, 0
|
||||
for _, trade := range trades {
|
||||
var id int64
|
||||
fmt.Sscanf(trade.TradeID, "%d", &id)
|
||||
if id < minID {
|
||||
minID = id
|
||||
}
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf(" %s: %d trades, ID range [%d - %d]", symbol, len(trades), minID, maxID)
|
||||
|
||||
// Check if any ID exceeds PostgreSQL INTEGER max
|
||||
if maxID > 2147483647 {
|
||||
t.Logf(" ⚠️ Max trade ID %d exceeds PostgreSQL INTEGER max (2147483647)", maxID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinanceIncomeAPIDirectCall makes direct API call to understand response
|
||||
func TestBinanceIncomeAPIDirectCall(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
trader := createBinanceTestTrader(t)
|
||||
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
t.Logf("🔍 Direct income API call from %s:", startTime.Format(time.RFC3339))
|
||||
t.Logf(" StartTime UnixMilli: %d", startTime.UnixMilli())
|
||||
|
||||
// Call without income type filter to get ALL income
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(1000).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get income: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("📋 Total income records: %d", len(incomes))
|
||||
|
||||
// Group by type and symbol
|
||||
typeSymbolCount := make(map[string]map[string]int)
|
||||
for _, inc := range incomes {
|
||||
if typeSymbolCount[inc.IncomeType] == nil {
|
||||
typeSymbolCount[inc.IncomeType] = make(map[string]int)
|
||||
}
|
||||
typeSymbolCount[inc.IncomeType][inc.Symbol]++
|
||||
}
|
||||
|
||||
for incType, symbols := range typeSymbolCount {
|
||||
t.Logf(" %s:", incType)
|
||||
for symbol, count := range symbols {
|
||||
if symbol == "" {
|
||||
symbol = "(no symbol)"
|
||||
}
|
||||
t.Logf(" %s: %d records", symbol, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Print sample records
|
||||
if len(incomes) > 0 {
|
||||
t.Logf("\n📝 Sample income records (first 5):")
|
||||
for i, inc := range incomes {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] Type=%s Symbol=%s Amount=%s Time=%s",
|
||||
i+1, inc.IncomeType, inc.Symbol, inc.Income,
|
||||
time.UnixMilli(inc.Time).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
}
|
||||
218
trader/binance_sync_e2e_test.go
Normal file
218
trader/binance_sync_e2e_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/store"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBinanceSyncE2E tests the complete sync flow end-to-end
|
||||
func TestBinanceSyncE2E(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
// Create test database using full store initialization (includes table creation)
|
||||
testDBPath := "/tmp/test_binance_sync.db"
|
||||
os.Remove(testDBPath) // Clean up previous test
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
|
||||
// Create trader
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
// Test parameters
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
t.Logf("🧪 Running end-to-end sync test...")
|
||||
t.Logf(" DB Path: %s", testDBPath)
|
||||
|
||||
// Run sync
|
||||
t.Logf("\n📥 Running SyncOrdersFromBinance...")
|
||||
startTime := time.Now()
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Sync completed in %v", elapsed)
|
||||
|
||||
// Check results in database
|
||||
orderStore := st.Order()
|
||||
|
||||
// Count orders
|
||||
var orderCount int64
|
||||
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&orderCount)
|
||||
t.Logf("\n📊 Results:")
|
||||
t.Logf(" Orders in DB: %d", orderCount)
|
||||
|
||||
// Count fills
|
||||
var fillCount int64
|
||||
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||
t.Logf(" Fills in DB: %d", fillCount)
|
||||
|
||||
// Get symbols
|
||||
var symbols []string
|
||||
db.Model(&store.TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ?", exchangeID).
|
||||
Pluck("symbol", &symbols)
|
||||
t.Logf(" Unique symbols: %d - %v", len(symbols), symbols)
|
||||
|
||||
// Check max trade IDs (test the fix)
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetMaxTradeIDsByExchange error: %v", err)
|
||||
} else {
|
||||
t.Logf(" Max trade IDs per symbol:")
|
||||
for symbol, maxID := range maxTradeIDs {
|
||||
if maxID > 2147483647 {
|
||||
t.Logf(" %s: %d (⚠️ exceeds PostgreSQL INTEGER max)", symbol, maxID)
|
||||
} else {
|
||||
t.Logf(" %s: %d", symbol, maxID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sample some orders
|
||||
var sampleOrders []store.TraderOrder
|
||||
db.Where("exchange_id = ?", exchangeID).Limit(5).Find(&sampleOrders)
|
||||
if len(sampleOrders) > 0 {
|
||||
t.Logf("\n📝 Sample orders:")
|
||||
for i, order := range sampleOrders {
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
|
||||
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
|
||||
order.Quantity, order.Price, order.OrderAction,
|
||||
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// Test incremental sync - run again, should find no new trades
|
||||
t.Logf("\n🔄 Running incremental sync (should skip existing trades)...")
|
||||
startTime = time.Now()
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
elapsed = time.Since(startTime)
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Incremental sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Incremental sync completed in %v", elapsed)
|
||||
|
||||
// Check counts again - should be the same
|
||||
var newOrderCount int64
|
||||
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&newOrderCount)
|
||||
t.Logf(" Orders after incremental sync: %d (was %d)", newOrderCount, orderCount)
|
||||
|
||||
if newOrderCount != orderCount {
|
||||
t.Logf(" ⚠️ Order count changed - possible duplicate detection issue")
|
||||
} else {
|
||||
t.Logf(" ✅ No duplicates - incremental sync working correctly")
|
||||
}
|
||||
|
||||
// Test GetLastFillTimeByExchange
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||
} else {
|
||||
lastFillTime := time.UnixMilli(lastFillTimeMs)
|
||||
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
// Check if it would be in the future (the bug we fixed)
|
||||
now := time.Now().UTC()
|
||||
if lastFillTime.After(now) {
|
||||
t.Logf(" ❌ BUG: Last fill time is in the future! (now: %s)", now.Format(time.RFC3339))
|
||||
} else {
|
||||
t.Logf(" ✅ Last fill time is in the past (correct)")
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
os.Remove(testDBPath)
|
||||
t.Logf("\n✅ E2E test completed successfully!")
|
||||
}
|
||||
|
||||
// TestBinanceSyncWithExistingData tests sync behavior with pre-existing data
|
||||
func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
testDBPath := "/tmp/test_binance_sync_existing.db"
|
||||
os.Remove(testDBPath)
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
orderStore := st.Order()
|
||||
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
// Insert a fake "old" fill with LOCAL time (simulating the bug scenario)
|
||||
// This tests that our timezone fix works
|
||||
localTime := time.Now().Add(8 * time.Hour) // Simulate +8 timezone stored as if it were UTC
|
||||
fakeFill := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangeOrderID: "fake-old-order",
|
||||
ExchangeTradeID: "fake-old-trade",
|
||||
Symbol: "BTCUSDT",
|
||||
Side: "BUY",
|
||||
Price: 50000,
|
||||
Quantity: 0.001,
|
||||
QuoteQuantity: 50,
|
||||
CreatedAt: localTime.UnixMilli(), // This time is "in the future" if interpreted as UTC
|
||||
}
|
||||
if err := orderStore.CreateFill(fakeFill); err != nil {
|
||||
t.Fatalf("Failed to create fake fill: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("🧪 Testing sync with existing 'future' data...")
|
||||
t.Logf(" Fake fill time: %s", localTime.Format(time.RFC3339))
|
||||
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Check GetLastFillTimeByExchange
|
||||
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
|
||||
|
||||
if lastFillTime2.After(time.Now().UTC()) {
|
||||
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
|
||||
}
|
||||
|
||||
// Run sync - it should detect the future time and fall back
|
||||
t.Logf("\n📥 Running sync (should detect future time and fall back)...")
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
if err != nil {
|
||||
t.Fatalf("❌ Sync failed: %v", err)
|
||||
}
|
||||
t.Logf("✅ Sync completed")
|
||||
|
||||
// Check that trades were actually synced despite the bad data
|
||||
var fillCount int64
|
||||
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||
t.Logf(" Total fills in DB: %d (includes 1 fake)", fillCount)
|
||||
|
||||
if fillCount > 1 {
|
||||
t.Logf(" ✅ Real trades were synced despite 'future' data!")
|
||||
} else {
|
||||
t.Logf(" ❌ No real trades synced - the bug might still exist")
|
||||
}
|
||||
|
||||
os.Remove(testDBPath)
|
||||
}
|
||||
511
trader/binance_sync_verify_test.go
Normal file
511
trader/binance_sync_verify_test.go
Normal file
@@ -0,0 +1,511 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func repeatStr(s string, n int) string {
|
||||
return strings.Repeat(s, n)
|
||||
}
|
||||
|
||||
// TestBinanceSyncVerification verifies synced data matches exchange data exactly
|
||||
func TestBinanceSyncVerification(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
|
||||
// Create test database
|
||||
testDBPath := "/tmp/test_binance_verify.db"
|
||||
os.Remove(testDBPath)
|
||||
|
||||
st, err := store.New(testDBPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to init test store: %v", err)
|
||||
}
|
||||
db := st.GormDB()
|
||||
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
traderID := "test-trader-id"
|
||||
exchangeID := "test-exchange-id"
|
||||
exchangeType := "binance"
|
||||
|
||||
// Step 1: Run sync
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 1: Running order sync...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Get all trades from exchange for verification
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 2: Fetching trades from exchange for verification...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||
|
||||
// Get symbols from DB
|
||||
var symbols []string
|
||||
db.Model(&store.TraderFill{}).
|
||||
Select("DISTINCT symbol").
|
||||
Where("exchange_id = ?", exchangeID).
|
||||
Pluck("symbol", &symbols)
|
||||
|
||||
t.Logf("Symbols to verify: %v", symbols)
|
||||
|
||||
// Fetch all trades from exchange
|
||||
type ExchangeTrade struct {
|
||||
TradeID string
|
||||
Symbol string
|
||||
Side string
|
||||
Price float64
|
||||
Quantity float64
|
||||
Fee float64
|
||||
RealizedPnL float64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
var exchangeTrades []ExchangeTrade
|
||||
for _, symbol := range symbols {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 1000)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
for _, trade := range trades {
|
||||
exchangeTrades = append(exchangeTrades, ExchangeTrade{
|
||||
TradeID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
Fee: trade.Fee,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
Time: trade.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Total trades from exchange: %d", len(exchangeTrades))
|
||||
|
||||
// Step 3: Get all fills from DB
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 3: Comparing with local database...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var dbFills []store.TraderFill
|
||||
db.Where("exchange_id = ?", exchangeID).Find(&dbFills)
|
||||
|
||||
t.Logf("Total fills in DB: %d", len(dbFills))
|
||||
|
||||
// Create maps for comparison
|
||||
exchangeTradeMap := make(map[string]ExchangeTrade)
|
||||
for _, t := range exchangeTrades {
|
||||
exchangeTradeMap[t.TradeID] = t
|
||||
}
|
||||
|
||||
dbFillMap := make(map[string]store.TraderFill)
|
||||
for _, f := range dbFills {
|
||||
dbFillMap[f.ExchangeTradeID] = f
|
||||
}
|
||||
|
||||
// Step 4: Check for missing trades
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 4: Checking for MISSING trades (in exchange but not in DB)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var missingTrades []ExchangeTrade
|
||||
for tradeID, trade := range exchangeTradeMap {
|
||||
if _, exists := dbFillMap[tradeID]; !exists {
|
||||
missingTrades = append(missingTrades, trade)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingTrades) > 0 {
|
||||
t.Logf("❌ MISSING %d trades:", len(missingTrades))
|
||||
for i, trade := range missingTrades {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(missingTrades)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s %s qty=%.6f price=%.4f time=%s",
|
||||
trade.TradeID, trade.Symbol, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.Time.Format(time.RFC3339))
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ No missing trades")
|
||||
}
|
||||
|
||||
// Step 5: Check for extra/duplicate trades
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 5: Checking for EXTRA trades (in DB but not in exchange)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
var extraTrades []store.TraderFill
|
||||
for tradeID, fill := range dbFillMap {
|
||||
if _, exists := exchangeTradeMap[tradeID]; !exists {
|
||||
extraTrades = append(extraTrades, fill)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraTrades) > 0 {
|
||||
t.Logf("❌ EXTRA %d trades in DB:", len(extraTrades))
|
||||
for i, fill := range extraTrades {
|
||||
if i >= 10 {
|
||||
t.Logf(" ... and %d more", len(extraTrades)-10)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s %s qty=%.6f price=%.4f",
|
||||
fill.ExchangeTradeID, fill.Symbol, fill.Side,
|
||||
fill.Quantity, fill.Price)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ No extra/duplicate trades")
|
||||
}
|
||||
|
||||
// Step 6: Check for data accuracy
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 6: Verifying data accuracy (price, qty, fee, pnl)...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
type DataMismatch struct {
|
||||
TradeID string
|
||||
Field string
|
||||
DB float64
|
||||
Exchange float64
|
||||
}
|
||||
|
||||
var mismatches []DataMismatch
|
||||
for tradeID, exchangeTrade := range exchangeTradeMap {
|
||||
dbFill, exists := dbFillMap[tradeID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compare price
|
||||
if !floatEqual(dbFill.Price, exchangeTrade.Price, 0.0001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Price",
|
||||
DB: dbFill.Price, Exchange: exchangeTrade.Price,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare quantity
|
||||
if !floatEqual(dbFill.Quantity, exchangeTrade.Quantity, 0.000001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Quantity",
|
||||
DB: dbFill.Quantity, Exchange: exchangeTrade.Quantity,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare fee
|
||||
if !floatEqual(dbFill.Commission, exchangeTrade.Fee, 0.000001) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "Fee",
|
||||
DB: dbFill.Commission, Exchange: exchangeTrade.Fee,
|
||||
})
|
||||
}
|
||||
|
||||
// Compare realized PnL
|
||||
if !floatEqual(dbFill.RealizedPnL, exchangeTrade.RealizedPnL, 0.01) {
|
||||
mismatches = append(mismatches, DataMismatch{
|
||||
TradeID: tradeID, Field: "RealizedPnL",
|
||||
DB: dbFill.RealizedPnL, Exchange: exchangeTrade.RealizedPnL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
t.Logf("❌ DATA MISMATCHES: %d", len(mismatches))
|
||||
for i, m := range mismatches {
|
||||
if i >= 20 {
|
||||
t.Logf(" ... and %d more", len(mismatches)-20)
|
||||
break
|
||||
}
|
||||
t.Logf(" - %s %s: DB=%.6f, Exchange=%.6f",
|
||||
m.TradeID, m.Field, m.DB, m.Exchange)
|
||||
}
|
||||
} else {
|
||||
t.Logf("✅ All data matches exactly")
|
||||
}
|
||||
|
||||
// Step 7: Summary by symbol
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 7: Summary by symbol...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
type SymbolSummary struct {
|
||||
Symbol string
|
||||
ExchangeCount int
|
||||
DBCount int
|
||||
TotalQty float64
|
||||
TotalFee float64
|
||||
TotalPnL float64
|
||||
ExchangeTotalQty float64
|
||||
ExchangeTotalFee float64
|
||||
ExchangeTotalPnL float64
|
||||
}
|
||||
|
||||
summaryMap := make(map[string]*SymbolSummary)
|
||||
|
||||
for _, trade := range exchangeTrades {
|
||||
if summaryMap[trade.Symbol] == nil {
|
||||
summaryMap[trade.Symbol] = &SymbolSummary{Symbol: trade.Symbol}
|
||||
}
|
||||
s := summaryMap[trade.Symbol]
|
||||
s.ExchangeCount++
|
||||
s.ExchangeTotalQty += trade.Quantity
|
||||
s.ExchangeTotalFee += trade.Fee
|
||||
s.ExchangeTotalPnL += trade.RealizedPnL
|
||||
}
|
||||
|
||||
for _, fill := range dbFills {
|
||||
if summaryMap[fill.Symbol] == nil {
|
||||
summaryMap[fill.Symbol] = &SymbolSummary{Symbol: fill.Symbol}
|
||||
}
|
||||
s := summaryMap[fill.Symbol]
|
||||
s.DBCount++
|
||||
s.TotalQty += fill.Quantity
|
||||
s.TotalFee += fill.Commission
|
||||
s.TotalPnL += fill.RealizedPnL
|
||||
}
|
||||
|
||||
t.Logf("\n%-15s %10s %10s %15s %15s %15s", "Symbol", "Exchange", "DB", "Fee(Exc/DB)", "PnL(Exc/DB)", "Match")
|
||||
t.Logf("%s", repeatStr("-", 80))
|
||||
|
||||
for _, s := range summaryMap {
|
||||
countMatch := s.ExchangeCount == s.DBCount
|
||||
feeMatch := floatEqual(s.ExchangeTotalFee, s.TotalFee, 0.01)
|
||||
pnlMatch := floatEqual(s.ExchangeTotalPnL, s.TotalPnL, 0.01)
|
||||
|
||||
matchStr := "✅"
|
||||
if !countMatch || !feeMatch || !pnlMatch {
|
||||
matchStr = "❌"
|
||||
}
|
||||
|
||||
t.Logf("%-15s %10d %10d %7.2f/%-7.2f %7.2f/%-7.2f %s",
|
||||
s.Symbol, s.ExchangeCount, s.DBCount,
|
||||
s.ExchangeTotalFee, s.TotalFee,
|
||||
s.ExchangeTotalPnL, s.TotalPnL,
|
||||
matchStr)
|
||||
}
|
||||
|
||||
// Step 8: Position verification
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("STEP 8: Verifying position calculations...")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
// Get positions from DB
|
||||
var dbPositions []store.TraderPosition
|
||||
db.Where("exchange_id = ? AND status = ?", exchangeID, "closed").Find(&dbPositions)
|
||||
|
||||
t.Logf("Closed positions in DB: %d", len(dbPositions))
|
||||
|
||||
// Get current positions from exchange
|
||||
exchangePositions, err := trader.GetPositions()
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get exchange positions: %v", err)
|
||||
} else {
|
||||
t.Logf("Active positions on exchange: %d", len(exchangePositions))
|
||||
for _, pos := range exchangePositions {
|
||||
t.Logf(" - %s %s qty=%.6f entry=%.4f pnl=%.4f",
|
||||
pos["symbol"], pos["side"],
|
||||
pos["positionAmt"], pos["entryPrice"], pos["unRealizedProfit"])
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total PnL from trades
|
||||
var totalRealizedPnL float64
|
||||
var totalFees float64
|
||||
for _, fill := range dbFills {
|
||||
totalRealizedPnL += fill.RealizedPnL
|
||||
totalFees += fill.Commission
|
||||
}
|
||||
|
||||
t.Logf("\n📊 PnL Summary from DB:")
|
||||
t.Logf(" Total Realized PnL: %.4f USDT", totalRealizedPnL)
|
||||
t.Logf(" Total Fees: %.4f USDT", totalFees)
|
||||
t.Logf(" Net PnL: %.4f USDT", totalRealizedPnL-totalFees)
|
||||
|
||||
// Calculate from exchange
|
||||
var exchangeTotalPnL float64
|
||||
var exchangeTotalFees float64
|
||||
for _, trade := range exchangeTrades {
|
||||
exchangeTotalPnL += trade.RealizedPnL
|
||||
exchangeTotalFees += trade.Fee
|
||||
}
|
||||
|
||||
t.Logf("\n📊 PnL Summary from Exchange:")
|
||||
t.Logf(" Total Realized PnL: %.4f USDT", exchangeTotalPnL)
|
||||
t.Logf(" Total Fees: %.4f USDT", exchangeTotalFees)
|
||||
t.Logf(" Net PnL: %.4f USDT", exchangeTotalPnL-exchangeTotalFees)
|
||||
|
||||
// Compare
|
||||
pnlMatch := floatEqual(totalRealizedPnL, exchangeTotalPnL, 0.01)
|
||||
feeMatch := floatEqual(totalFees, exchangeTotalFees, 0.01)
|
||||
|
||||
t.Logf("\n%s", repeatStr("=", 60))
|
||||
t.Logf("FINAL VERIFICATION RESULT")
|
||||
t.Logf("%s", repeatStr("=", 60))
|
||||
|
||||
allPassed := true
|
||||
|
||||
if len(missingTrades) > 0 {
|
||||
t.Logf("❌ Missing trades: %d", len(missingTrades))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ No missing trades")
|
||||
}
|
||||
|
||||
if len(extraTrades) > 0 {
|
||||
t.Logf("❌ Extra/duplicate trades: %d", len(extraTrades))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ No extra/duplicate trades")
|
||||
}
|
||||
|
||||
if len(mismatches) > 0 {
|
||||
t.Logf("❌ Data mismatches: %d", len(mismatches))
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ All data accurate")
|
||||
}
|
||||
|
||||
if !pnlMatch {
|
||||
t.Logf("❌ PnL mismatch: DB=%.4f, Exchange=%.4f", totalRealizedPnL, exchangeTotalPnL)
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ PnL matches")
|
||||
}
|
||||
|
||||
if !feeMatch {
|
||||
t.Logf("❌ Fee mismatch: DB=%.4f, Exchange=%.4f", totalFees, exchangeTotalFees)
|
||||
allPassed = false
|
||||
} else {
|
||||
t.Logf("✅ Fees match")
|
||||
}
|
||||
|
||||
if allPassed {
|
||||
t.Logf("\n🎉 ALL VERIFICATIONS PASSED!")
|
||||
} else {
|
||||
t.Logf("\n⚠️ SOME VERIFICATIONS FAILED - CHECK ABOVE FOR DETAILS")
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
os.Remove(testDBPath)
|
||||
}
|
||||
|
||||
// floatEqual compares two floats with tolerance
|
||||
func floatEqual(a, b, tolerance float64) bool {
|
||||
return math.Abs(a-b) <= tolerance
|
||||
}
|
||||
|
||||
// TestBinanceDetailedTradeComparison shows detailed trade-by-trade comparison
|
||||
func TestBinanceDetailedTradeComparison(t *testing.T) {
|
||||
skipIfNoLiveTest(t)
|
||||
|
||||
// Get credentials from environment
|
||||
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||
|
||||
startTime := time.Now().UTC().Add(-24 * time.Hour)
|
||||
|
||||
// Get all income (to find symbols with activity)
|
||||
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||
StartTime(startTime.UnixMilli()).
|
||||
Limit(100).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get income: %v", err)
|
||||
}
|
||||
|
||||
// Find unique symbols
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, inc := range incomes {
|
||||
if inc.Symbol != "" {
|
||||
symbolMap[inc.Symbol] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(symbolMap) == 0 {
|
||||
t.Log("No trading activity in the last 24 hours")
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
t.Logf("DETAILED TRADE REPORT (Last 24 hours)")
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
|
||||
var grandTotalQty float64
|
||||
var grandTotalFee float64
|
||||
var grandTotalPnL float64
|
||||
|
||||
for symbol := range symbolMap {
|
||||
trades, err := trader.GetTradesForSymbol(symbol, startTime, 500)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(trades) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
})
|
||||
|
||||
t.Logf("\n%s", repeatStr("-", 100))
|
||||
t.Logf("📊 %s - %d trades", symbol, len(trades))
|
||||
t.Logf("%s", repeatStr("-", 100))
|
||||
t.Logf("%-15s %-6s %12s %12s %12s %12s %20s",
|
||||
"TradeID", "Side", "Quantity", "Price", "Fee", "PnL", "Time")
|
||||
|
||||
var totalQty, totalFee, totalPnL float64
|
||||
var buyQty, sellQty float64
|
||||
|
||||
for _, trade := range trades {
|
||||
t.Logf("%-15s %-6s %12.6f %12.4f %12.6f %12.4f %20s",
|
||||
trade.TradeID, trade.Side,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time.Format("2006-01-02 15:04:05"))
|
||||
|
||||
totalQty += trade.Quantity
|
||||
totalFee += trade.Fee
|
||||
totalPnL += trade.RealizedPnL
|
||||
|
||||
if trade.Side == "BUY" {
|
||||
buyQty += trade.Quantity
|
||||
} else {
|
||||
sellQty += trade.Quantity
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("%s", repeatStr("-", 100))
|
||||
t.Logf("SUBTOTAL: %d trades, Buy=%.6f, Sell=%.6f, Fee=%.6f, PnL=%.4f",
|
||||
len(trades), buyQty, sellQty, totalFee, totalPnL)
|
||||
|
||||
grandTotalQty += totalQty
|
||||
grandTotalFee += totalFee
|
||||
grandTotalPnL += totalPnL
|
||||
}
|
||||
|
||||
t.Logf("\n%s", repeatStr("=", 100))
|
||||
t.Logf("GRAND TOTAL")
|
||||
t.Logf("=%s", repeatStr("=", 100))
|
||||
t.Logf("Total Fee: %.6f USDT", grandTotalFee)
|
||||
t.Logf("Total PnL: %.4f USDT", grandTotalPnL)
|
||||
t.Logf("Net PnL: %.4f USDT", grandTotalPnL-grandTotalFee)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
|
||||
FillQty: fillQty,
|
||||
Fee: -fee, // Bitget returns negative fee
|
||||
FeeAsset: fill.FeeCcy,
|
||||
ExecTime: time.UnixMilli(cTime),
|
||||
ExecTime: time.UnixMilli(cTime).UTC(),
|
||||
ProfitLoss: profit,
|
||||
OrderType: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
@@ -146,7 +146,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -174,7 +174,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -191,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
FilledQuantity: trade.FillQty,
|
||||
AvgFillPrice: trade.FillPrice,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.ExecTime,
|
||||
CreatedAt: trade.ExecTime,
|
||||
UpdatedAt: trade.ExecTime,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -202,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use UTC time in milliseconds
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -219,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
CommissionAsset: trade.FeeAsset,
|
||||
RealizedPnL: trade.ProfitLoss,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.ExecTime,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -231,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
trade.ExecTime, trade.TradeID,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -1069,8 +1069,8 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
|
||||
|
||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||
record.EntryTime = time.UnixMilli(cTime)
|
||||
record.ExitTime = time.UnixMilli(uTime)
|
||||
record.EntryTime = time.UnixMilli(cTime).UTC()
|
||||
record.ExitTime = time.UnixMilli(uTime).UTC()
|
||||
|
||||
record.CloseType = "unknown"
|
||||
records = append(records, record)
|
||||
@@ -1096,3 +1096,243 @@ func genBitgetClientOid() string {
|
||||
rand := time.Now().Nanosecond() % 100000
|
||||
return fmt.Sprintf("nofx%d%05d", timestamp, rand)
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", bitgetPendingPath, params)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
TradeSide string `json:"tradeSide"` // open/close
|
||||
PosSide string `json:"posSide"` // long/short
|
||||
OrderType string `json:"orderType"` // limit/market
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders.EntrustedList {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
// Convert side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrderType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
||||
planParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
|
||||
}
|
||||
if err == nil && planData != nil {
|
||||
var planOrders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||
for _, order := range planOrders.EntrustedList {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
// Map Bitget plan type to order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.PlanType == "profit_plan" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Format quantity
|
||||
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
|
||||
|
||||
// Determine side
|
||||
side := "buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"marginMode": "crossed",
|
||||
"marginCoin": "USDT",
|
||||
"side": side,
|
||||
"orderType": "limit",
|
||||
"size": qtyStr,
|
||||
"price": fmt.Sprintf("%.8f", req.Price),
|
||||
"force": "GTC", // Good Till Cancel
|
||||
"clientOid": genBitgetClientOid(),
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = "YES"
|
||||
}
|
||||
|
||||
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
|
||||
|
||||
data, err := t.doRequest("POST", bitgetOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var order struct {
|
||||
OrderId string `json:"orderId"`
|
||||
ClientOid string `json:"clientOid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (t *BybitTrader) parseTradesResult(list []map[string]interface{}) ([]BybitT
|
||||
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
|
||||
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
|
||||
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
|
||||
execTime := time.UnixMilli(execTimeMs)
|
||||
execTime := time.UnixMilli(execTimeMs).UTC()
|
||||
|
||||
// Determine order action based on side and closedSize
|
||||
// If closedSize > 0, it's a close trade
|
||||
@@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -223,7 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -240,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
FilledQuantity: trade.ExecQty,
|
||||
AvgFillPrice: trade.ExecPrice,
|
||||
Commission: trade.ExecFee,
|
||||
FilledAt: trade.ExecTime,
|
||||
CreatedAt: trade.ExecTime,
|
||||
UpdatedAt: trade.ExecTime,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -251,7 +252,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use UTC time
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -268,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.ClosedPnL,
|
||||
IsMaker: trade.IsMaker,
|
||||
CreatedAt: trade.ExecTime,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -280,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
||||
trade.ExecTime, trade.ExecID,
|
||||
execTimeMs, trade.ExecID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
||||
} else {
|
||||
|
||||
@@ -1032,8 +1032,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
RealizedPnL: closedPnL,
|
||||
Fee: fee,
|
||||
Leverage: int(leverage),
|
||||
EntryTime: time.UnixMilli(createdTime),
|
||||
ExitTime: time.UnixMilli(updatedTime),
|
||||
EntryTime: time.UnixMilli(createdTime).UTC(),
|
||||
ExitTime: time.UnixMilli(updatedTime).UTC(),
|
||||
OrderID: orderId,
|
||||
CloseType: "unknown", // Bybit doesn't provide close type directly
|
||||
ExchangeID: orderId, // Use orderId as exchange ID
|
||||
@@ -1044,3 +1044,220 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
var result []OpenOrder
|
||||
|
||||
// Get conditional orders (stop-loss, take-profit)
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderFilter": "StopOrder",
|
||||
}
|
||||
|
||||
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
if resp.RetCode == 0 {
|
||||
resultData, ok := resp.Result.(map[string]interface{})
|
||||
if ok {
|
||||
list, _ := resultData["list"].([]interface{})
|
||||
for _, item := range list {
|
||||
order, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
orderId, _ := order["orderId"].(string)
|
||||
sym, _ := order["symbol"].(string)
|
||||
side, _ := order["side"].(string)
|
||||
orderType, _ := order["orderType"].(string)
|
||||
stopOrderType, _ := order["stopOrderType"].(string)
|
||||
triggerPrice, _ := order["triggerPrice"].(string)
|
||||
qty, _ := order["qty"].(string)
|
||||
|
||||
price, _ := strconv.ParseFloat(triggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(qty, 64)
|
||||
|
||||
// Determine type based on stopOrderType
|
||||
displayType := orderType
|
||||
if stopOrderType != "" {
|
||||
displayType = stopOrderType
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: orderId,
|
||||
Symbol: sym,
|
||||
Side: side,
|
||||
PositionSide: "", // Bybit doesn't use positionSide for UTA
|
||||
Type: displayType,
|
||||
Price: 0,
|
||||
StopPrice: price,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price
|
||||
priceStr := fmt.Sprintf("%.8f", req.Price)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side
|
||||
side := "Buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "Sell"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": req.Symbol,
|
||||
"side": side,
|
||||
"orderType": "Limit",
|
||||
"qty": qtyStr,
|
||||
"price": priceStr,
|
||||
"timeInForce": "GTC", // Good Till Cancel
|
||||
"positionIdx": 0, // One-way position mode
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Parse result
|
||||
orderID := ""
|
||||
if result.RetCode == 0 {
|
||||
if resultData, ok := result.Result.(map[string]interface{}); ok {
|
||||
if id, ok := resultData["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 25
|
||||
}
|
||||
|
||||
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
|
||||
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result struct {
|
||||
S string `json:"s"` // symbol
|
||||
B [][]string `json:"b"` // bids [[price, size], ...]
|
||||
A [][]string `json:"a"` // asks [[price, size], ...]
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Result.B {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Result.A {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func runStandardTests(t *testing.T, exchangeName string) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, trade.Side, trade.Action,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "open_long",
|
||||
0.1, 3500+float64(i*10), 0.5, 0,
|
||||
time.Now().Add(time.Duration(i*2)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "close_long",
|
||||
0.1, 3600+float64(i*10), 0.5, 10,
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "open_long",
|
||||
0.01, 50000, 1.0, 0,
|
||||
time.Now(),
|
||||
time.Now().UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "close_long",
|
||||
0.00999999, 51000, 1.0, 10,
|
||||
time.Now().Add(time.Second),
|
||||
time.Now().Add(time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
196
trader/grid_regime.go
Normal file
196
trader/grid_regime.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Task 6: Regime Level Classification
|
||||
// ============================================================================
|
||||
|
||||
// classifyRegimeLevel determines the regime level based on market indicators
|
||||
// bollingerWidth: Bollinger band width as percentage
|
||||
// atr14Pct: ATR14 as percentage of current price
|
||||
func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel {
|
||||
// Narrow: Bollinger < 2%, ATR < 1%
|
||||
if bollingerWidth < 2.0 && atr14Pct < 1.0 {
|
||||
return market.RegimeLevelNarrow
|
||||
}
|
||||
|
||||
// Standard: Bollinger 2-3%, ATR 1-2%
|
||||
if bollingerWidth <= 3.0 && atr14Pct <= 2.0 {
|
||||
return market.RegimeLevelStandard
|
||||
}
|
||||
|
||||
// Wide: Bollinger 3-4%, ATR 2-3%
|
||||
if bollingerWidth <= 4.0 && atr14Pct <= 3.0 {
|
||||
return market.RegimeLevelWide
|
||||
}
|
||||
|
||||
// Volatile: Bollinger > 4%, ATR > 3%
|
||||
return market.RegimeLevelVolatile
|
||||
}
|
||||
|
||||
// getRegimeLeverageLimit returns the effective leverage limit for a regime level
|
||||
func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridConfigModel) int {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimeLeverage > 0 {
|
||||
return config.NarrowRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimeLeverage > 0 {
|
||||
return config.StandardRegimeLeverage
|
||||
}
|
||||
return 4
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimeLeverage > 0 {
|
||||
return config.WideRegimeLeverage
|
||||
}
|
||||
return 3
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimeLeverage > 0 {
|
||||
return config.VolatileRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
default:
|
||||
return 2 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// getRegimePositionLimit returns the position limit percentage for a regime level
|
||||
func getRegimePositionLimit(level market.RegimeLevel, config *store.GridConfigModel) float64 {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimePositionPct > 0 {
|
||||
return config.NarrowRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimePositionPct > 0 {
|
||||
return config.StandardRegimePositionPct
|
||||
}
|
||||
return 70.0
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimePositionPct > 0 {
|
||||
return config.WideRegimePositionPct
|
||||
}
|
||||
return 60.0
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimePositionPct > 0 {
|
||||
return config.VolatileRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
default:
|
||||
return 40.0 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 7: Breakout Detection
|
||||
// ============================================================================
|
||||
|
||||
// detectBoxBreakout checks if price has broken out of any box level
|
||||
// Returns the highest breakout level and direction
|
||||
func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) {
|
||||
if box == nil {
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
// Check long box first (highest priority)
|
||||
if price > box.LongUpper {
|
||||
return market.BreakoutLong, "up"
|
||||
}
|
||||
if price < box.LongLower {
|
||||
return market.BreakoutLong, "down"
|
||||
}
|
||||
|
||||
// Check mid box
|
||||
if price > box.MidUpper {
|
||||
return market.BreakoutMid, "up"
|
||||
}
|
||||
if price < box.MidLower {
|
||||
return market.BreakoutMid, "down"
|
||||
}
|
||||
|
||||
// Check short box
|
||||
if price > box.ShortUpper {
|
||||
return market.BreakoutShort, "up"
|
||||
}
|
||||
if price < box.ShortLower {
|
||||
return market.BreakoutShort, "down"
|
||||
}
|
||||
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 8: Breakout Confirmation Logic
|
||||
// ============================================================================
|
||||
|
||||
const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout
|
||||
|
||||
// BreakoutState tracks the current breakout state
|
||||
type BreakoutState struct {
|
||||
Level market.BreakoutLevel
|
||||
Direction string
|
||||
ConfirmCount int
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// confirmBreakout updates breakout state and returns true if breakout is confirmed
|
||||
func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool {
|
||||
// If price returned to box, reset state
|
||||
if currentLevel == market.BreakoutNone {
|
||||
state.ConfirmCount = 0
|
||||
state.Level = market.BreakoutNone
|
||||
state.Direction = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// If same breakout continues, increment count
|
||||
if state.Level == currentLevel && state.Direction == direction {
|
||||
state.ConfirmCount++
|
||||
} else {
|
||||
// New breakout, reset count
|
||||
state.Level = currentLevel
|
||||
state.Direction = direction
|
||||
state.ConfirmCount = 1
|
||||
state.StartTime = time.Now()
|
||||
}
|
||||
|
||||
return state.ConfirmCount >= BreakoutConfirmRequired
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 9: Breakout Handler
|
||||
// ============================================================================
|
||||
|
||||
// BreakoutAction represents the action to take on breakout
|
||||
type BreakoutAction int
|
||||
|
||||
const (
|
||||
BreakoutActionNone BreakoutAction = iota
|
||||
BreakoutActionReducePosition // Short box breakout: reduce to 50%
|
||||
BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders
|
||||
BreakoutActionCloseAll // Long box breakout: pause + cancel + close all
|
||||
)
|
||||
|
||||
// getBreakoutAction returns the appropriate action for a breakout level
|
||||
func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
return BreakoutActionReducePosition
|
||||
case market.BreakoutMid:
|
||||
return BreakoutActionPauseGrid
|
||||
case market.BreakoutLong:
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
122
trader/grid_regime_test.go
Normal file
122
trader/grid_regime_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyRegimeLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bollingerWidth float64
|
||||
atr14Pct float64
|
||||
expected market.RegimeLevel
|
||||
}{
|
||||
{"narrow", 1.5, 0.8, market.RegimeLevelNarrow},
|
||||
{"standard", 2.5, 1.5, market.RegimeLevelStandard},
|
||||
{"wide", 3.5, 2.5, market.RegimeLevelWide},
|
||||
{"volatile", 5.0, 4.0, market.RegimeLevelVolatile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBoxBreakout(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
// No breakout
|
||||
level, direction := detectBoxBreakout(box)
|
||||
if level != market.BreakoutNone {
|
||||
t.Errorf("Expected no breakout, got %v", level)
|
||||
}
|
||||
|
||||
// Short breakout up
|
||||
box.CurrentPrice = 101
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutShort || direction != "up" {
|
||||
t.Errorf("Expected short breakout up, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Mid breakout down
|
||||
box.CurrentPrice = 84
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutMid || direction != "down" {
|
||||
t.Errorf("Expected mid breakout down, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Long breakout up
|
||||
box.CurrentPrice = 112
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutLong || direction != "up" {
|
||||
t.Errorf("Expected long breakout up, got %v %v", level, direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakoutConfirmation(t *testing.T) {
|
||||
state := &BreakoutState{
|
||||
Level: market.BreakoutNone,
|
||||
Direction: "",
|
||||
ConfirmCount: 0,
|
||||
}
|
||||
|
||||
// First detection
|
||||
confirmed := confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 1 {
|
||||
t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Second confirmation
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 2 {
|
||||
t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Third confirmation - should confirm
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if !confirmed || state.ConfirmCount != 3 {
|
||||
t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Reset on price return
|
||||
state.ConfirmCount = 2
|
||||
confirmed = confirmBreakout(state, market.BreakoutNone, "")
|
||||
if state.ConfirmCount != 0 {
|
||||
t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
level market.BreakoutLevel
|
||||
expected BreakoutAction
|
||||
}{
|
||||
{market.BreakoutNone, BreakoutActionNone},
|
||||
{market.BreakoutShort, BreakoutActionReducePosition},
|
||||
{market.BreakoutMid, BreakoutActionPauseGrid},
|
||||
{market.BreakoutLong, BreakoutActionCloseAll},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.level), func(t *testing.T) {
|
||||
action := getBreakoutAction(tt.level)
|
||||
if action != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -61,7 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -78,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -89,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -106,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -118,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-1",
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now(), "order-2",
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now(), "order-3",
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now(), "order-4",
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-5",
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now(), "order-6",
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now(), "order-7",
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now(), "order-8",
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now(), "order-9",
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1402,15 +1402,12 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction with builder (xyz dex requires builder info for order routing)
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
},
|
||||
Builder: nil,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -1592,15 +1589,12 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction with builder
|
||||
// Create OrderAction (no builder to avoid requiring builder fee approval)
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
},
|
||||
Builder: nil,
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
@@ -2070,7 +2064,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(fill.Time),
|
||||
Time: time.UnixMilli(fill.Time).UTC(),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
@@ -2079,7 +2073,159 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
|
||||
}
|
||||
|
||||
// defaultBuilder is the builder info for order routing
|
||||
var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
// Set to nil to avoid requiring builder fee approval
|
||||
//
|
||||
// var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
// Fee: 10,
|
||||
// }
|
||||
var defaultBuilder *hyperliquid.BuilderInfo = nil
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range openOrders {
|
||||
if order.Coin != symbol {
|
||||
continue
|
||||
}
|
||||
|
||||
side := "BUY"
|
||||
if order.Side == "A" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.Oid),
|
||||
Symbol: order.Coin,
|
||||
Side: side,
|
||||
PositionSide: "",
|
||||
Type: "LIMIT",
|
||||
Price: order.LimitPx,
|
||||
StopPrice: 0,
|
||||
Quantity: order.Size,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
if req.Leverage > 0 && !isXyz {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round quantity to allowed decimals
|
||||
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
|
||||
|
||||
// Round price to 5 significant figures
|
||||
roundedPrice := t.roundPriceToSigfigs(req.Price)
|
||||
|
||||
// Determine if buy or sell
|
||||
isBuy := req.Side == "BUY"
|
||||
|
||||
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
|
||||
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity,
|
||||
Price: roundedPrice,
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Limit: &hyperliquid.LimitOrderType{
|
||||
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
|
||||
},
|
||||
},
|
||||
ReduceOnly: req.ReduceOnly,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Note: Hyperliquid's Order response doesn't return the order ID directly
|
||||
// We would need to query open orders to get it, but for grid trading
|
||||
// we can track orders by price level instead
|
||||
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: roundedPrice,
|
||||
Quantity: roundedQuantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Parse order ID
|
||||
oid, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.exchange.Cancel(t.ctx, coin, oid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
if l2Book == nil || len(l2Book.Levels) < 2 {
|
||||
return nil, nil, fmt.Errorf("invalid order book data")
|
||||
}
|
||||
|
||||
// Parse bids (first level array)
|
||||
for i, level := range l2Book.Levels[0] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
bids = append(bids, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
// Parse asks (second level array)
|
||||
for i, level := range l2Book.Levels[1] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
asks = append(asks, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package trader
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClosedPnLRecord represents a single closed position record from exchange
|
||||
type ClosedPnLRecord struct {
|
||||
@@ -94,4 +98,133 @@ type Trader interface {
|
||||
// limit: max number of records to return
|
||||
// Returns accurate exit price, fees, and close reason for positions closed externally
|
||||
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
|
||||
|
||||
// GetOpenOrders Get open/pending orders from exchange
|
||||
// Returns stop-loss, take-profit, and limit orders that haven't been filled
|
||||
GetOpenOrders(symbol string) ([]OpenOrder, error)
|
||||
}
|
||||
|
||||
// OpenOrder represents a pending order on the exchange
|
||||
type OpenOrder struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT
|
||||
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
|
||||
Price float64 `json:"price"` // Order price (for limit orders)
|
||||
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW
|
||||
}
|
||||
|
||||
// LimitOrderRequest represents a limit order request for grid trading
|
||||
type LimitOrderRequest struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
|
||||
Price float64 `json:"price"` // Limit price
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
PostOnly bool `json:"post_only"` // Maker only order
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce position only
|
||||
ClientID string `json:"client_id"` // Client order ID for tracking
|
||||
}
|
||||
|
||||
// LimitOrderResult represents the result of placing a limit order
|
||||
type LimitOrderResult struct {
|
||||
OrderID string `json:"order_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"position_side"`
|
||||
Price float64 `json:"price"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
|
||||
}
|
||||
|
||||
// GridTrader extends Trader interface with limit order support for grid trading
|
||||
// Exchanges that support grid trading should implement this interface
|
||||
type GridTrader interface {
|
||||
Trader
|
||||
|
||||
// PlaceLimitOrder places a limit order at specified price
|
||||
// Returns order ID and status
|
||||
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
CancelOrder(symbol, orderID string) error
|
||||
|
||||
// GetOrderBook gets current order book (for price validation)
|
||||
// Returns best bid/ask prices
|
||||
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
|
||||
}
|
||||
|
||||
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
|
||||
// Uses stop orders as a fallback when limit orders aren't directly available
|
||||
type GridTraderAdapter struct {
|
||||
Trader
|
||||
}
|
||||
|
||||
// NewGridTraderAdapter creates an adapter for basic Trader
|
||||
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
|
||||
return &GridTraderAdapter{Trader: t}
|
||||
}
|
||||
|
||||
// PlaceLimitOrder implements limit order using available methods
|
||||
// For exchanges without native limit order support, this uses conditional orders
|
||||
func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// CRITICAL FIX: Set leverage before placing order
|
||||
if req.Leverage > 0 {
|
||||
if err := a.Trader.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Grid] Failed to set leverage %dx: %v", req.Leverage, err)
|
||||
// Continue anyway - some exchanges don't require explicit leverage setting
|
||||
}
|
||||
}
|
||||
|
||||
// Use SetStopLoss/SetTakeProfit as conditional limit orders
|
||||
// For buy orders below current price, use stop-loss mechanism
|
||||
// For sell orders above current price, use take-profit mechanism
|
||||
var err error
|
||||
if req.Side == "BUY" {
|
||||
err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price)
|
||||
} else {
|
||||
err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LimitOrderResult{
|
||||
OrderID: req.ClientID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order
|
||||
func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error {
|
||||
// Try to use CancelOrder if trader supports it directly
|
||||
if canceler, ok := a.Trader.(interface {
|
||||
CancelOrder(symbol, orderID string) error
|
||||
}); ok {
|
||||
return canceler.CancelOrder(symbol, orderID)
|
||||
}
|
||||
|
||||
// For traders that only support CancelAllOrders, log a warning
|
||||
// This is a limitation - we cannot cancel individual orders
|
||||
logger.Warnf("[Grid] Trader does not support individual order cancellation, "+
|
||||
"cannot cancel order %s. Consider using exchange-specific GridTrader implementation.", orderID)
|
||||
|
||||
// Return error instead of canceling all orders
|
||||
return fmt.Errorf("individual order cancellation not supported for this exchange")
|
||||
}
|
||||
|
||||
// GetOrderBook returns empty order book (not supported in basic Trader)
|
||||
func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Not supported, return empty
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test configuration - uses real account
|
||||
// Run with: LIGHTER_TEST=1 go test -v ./trader -run TestLighter -timeout 120s
|
||||
const (
|
||||
testWalletAddr = ""
|
||||
testAPIKeyPrivateKey = ""
|
||||
testAPIKeyIndex = 0
|
||||
testAccountIndex = int64(681514)
|
||||
)
|
||||
// Test configuration - uses environment variables for security
|
||||
// Run with:
|
||||
// LIGHTER_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... LIGHTER_API_KEY_INDEX=2 go test -v ./trader -run TestLighter -timeout 300s
|
||||
// Run with trading:
|
||||
// LIGHTER_TEST=1 LIGHTER_TRADE_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go test -v ./trader -run TestLighter -timeout 300s
|
||||
|
||||
// getTestConfig returns test configuration from environment variables
|
||||
func getTestConfig() (walletAddr, apiKey string, apiKeyIndex int) {
|
||||
walletAddr = os.Getenv("LIGHTER_WALLET")
|
||||
apiKey = os.Getenv("LIGHTER_API_KEY")
|
||||
// All credentials must be provided via environment variables for security
|
||||
apiKeyIndex = 2 // Default to index 2 (more stable than index 0)
|
||||
if idx := os.Getenv("LIGHTER_API_KEY_INDEX"); idx != "" {
|
||||
fmt.Sscanf(idx, "%d", &apiKeyIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func skipIfNoEnv(t *testing.T) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
t.Skip("Skipping Lighter integration test. Set LIGHTER_TEST=1 to run")
|
||||
}
|
||||
if os.Getenv("LIGHTER_WALLET") == "" {
|
||||
t.Skip("Skipping: LIGHTER_WALLET environment variable not set")
|
||||
}
|
||||
if os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
t.Skip("Skipping: LIGHTER_API_KEY environment variable not set")
|
||||
}
|
||||
}
|
||||
|
||||
// skipIfJurisdictionRestricted checks if error is due to geographic restriction
|
||||
@@ -31,7 +47,8 @@ func skipIfJurisdictionRestricted(t *testing.T, err error) {
|
||||
}
|
||||
|
||||
func createTestTrader(t *testing.T) *LighterTraderV2 {
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -46,9 +63,9 @@ func TestLighterAccountInit(t *testing.T) {
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Verify account index
|
||||
if trader.accountIndex != testAccountIndex {
|
||||
t.Errorf("Expected account index %d, got %d", testAccountIndex, trader.accountIndex)
|
||||
// Verify account index is valid (non-zero)
|
||||
if trader.accountIndex <= 0 {
|
||||
t.Errorf("Expected valid account index, got %d", trader.accountIndex)
|
||||
}
|
||||
|
||||
t.Logf("✅ Account initialized: index=%d", trader.accountIndex)
|
||||
@@ -253,11 +270,11 @@ func TestLighterCreateAndCancelLimitOrder(t *testing.T) {
|
||||
t.Fatalf("CreateOrder failed: %v", err)
|
||||
}
|
||||
|
||||
orderID, _ := result["order_id"].(string)
|
||||
orderID, _ := result["orderId"].(string)
|
||||
t.Logf("✅ Order created: %s", orderID)
|
||||
|
||||
if orderID == "" {
|
||||
t.Fatal("Expected order ID in response")
|
||||
t.Fatal("Expected orderId in response")
|
||||
}
|
||||
|
||||
// Wait a moment for order to be processed
|
||||
@@ -517,11 +534,12 @@ func TestLighterOrderSync(t *testing.T) {
|
||||
// ==================== Benchmark Tests ====================
|
||||
|
||||
func BenchmarkLighterGetBalance(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
if os.Getenv("LIGHTER_TEST") != "1" || os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 and LIGHTER_API_KEY to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -537,11 +555,12 @@ func BenchmarkLighterGetBalance(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
if os.Getenv("LIGHTER_TEST") != "1" || os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 and LIGHTER_API_KEY to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -555,3 +574,533 @@ func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetOpenOrders Tests ====================
|
||||
|
||||
func TestLighterGetOpenOrders(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetOpenOrders
|
||||
orders, err := trader.GetOpenOrders("ETH")
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOpenOrders failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetOpenOrders: found %d open orders", len(orders))
|
||||
for i, order := range orders {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(orders)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s %s: qty=%.4f @ %.2f, status=%s",
|
||||
i+1, order.Symbol, order.Side, order.Type, order.Quantity, order.Price, order.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterGetActiveOrders(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetActiveOrders (internal API)
|
||||
orders, err := trader.GetActiveOrders("ETH")
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveOrders failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetActiveOrders: found %d active orders", len(orders))
|
||||
for i, order := range orders {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(orders)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] OrderID=%s, Type=%s, Price=%s, RemainingAmount=%s",
|
||||
i+1, order.OrderID, order.Type, order.Price, order.RemainingBaseAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OrderBook Tests ====================
|
||||
|
||||
func TestLighterGetOrderBook(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetOrderBook
|
||||
bids, asks, err := trader.GetOrderBook("ETH", 10)
|
||||
if err != nil {
|
||||
// OrderBook API may not be available in all regions or require special permissions
|
||||
if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "restricted") {
|
||||
t.Skipf("Skipping: OrderBook API not available: %v", err)
|
||||
}
|
||||
t.Fatalf("GetOrderBook failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetOrderBook: %d bids, %d asks", len(bids), len(asks))
|
||||
|
||||
if len(bids) > 0 {
|
||||
t.Logf(" Best Bid: %.2f @ %.4f", bids[0][0], bids[0][1])
|
||||
}
|
||||
if len(asks) > 0 {
|
||||
t.Logf(" Best Ask: %.2f @ %.4f", asks[0][0], asks[0][1])
|
||||
}
|
||||
|
||||
// Verify spread makes sense
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
spread := asks[0][0] - bids[0][0]
|
||||
spreadPct := spread / bids[0][0] * 100
|
||||
t.Logf(" Spread: %.2f (%.4f%%)", spread, spreadPct)
|
||||
|
||||
if spread < 0 {
|
||||
t.Error("Invalid spread: ask < bid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PlaceLimitOrder (GridTrader) Tests ====================
|
||||
|
||||
func TestLighterPlaceLimitOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get current market price
|
||||
marketPrice, err := trader.GetMarketPrice("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
t.Logf("Current ETH price: %.2f", marketPrice)
|
||||
|
||||
// Create a limit order using PlaceLimitOrder (GridTrader interface)
|
||||
// Buy order at 75% of market price (won't fill)
|
||||
limitPrice := marketPrice * 0.75
|
||||
quantity := 0.01
|
||||
|
||||
req := &LimitOrderRequest{
|
||||
Symbol: "ETH",
|
||||
Side: "BUY",
|
||||
PositionSide: "LONG",
|
||||
Price: limitPrice,
|
||||
Quantity: quantity,
|
||||
Leverage: 10,
|
||||
ClientID: "test-order-001",
|
||||
ReduceOnly: false,
|
||||
}
|
||||
|
||||
t.Logf("Placing limit order via PlaceLimitOrder: %s %.4f @ %.2f", req.Side, req.Quantity, req.Price)
|
||||
|
||||
result, err := trader.PlaceLimitOrder(req)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("PlaceLimitOrder failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ PlaceLimitOrder result: OrderID=%s, Status=%s", result.OrderID, result.Status)
|
||||
|
||||
if result.OrderID == "" {
|
||||
t.Fatal("Expected OrderID in result")
|
||||
}
|
||||
|
||||
// Wait and cancel
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Cancel the order
|
||||
err = trader.CancelOrder("ETH", result.OrderID)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to cancel order: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Order cancelled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SetMarginMode Tests ====================
|
||||
|
||||
func TestLighterSetMarginMode(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test setting cross margin
|
||||
t.Log("Setting margin mode to CROSS...")
|
||||
err := trader.SetMarginMode("ETH", true)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetMarginMode(cross) failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetMarginMode(cross) succeeded")
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Note: Isolated margin may fail if there's an open position
|
||||
// Just test cross margin for safety
|
||||
}
|
||||
|
||||
// ==================== Stop-Loss/Take-Profit Tests ====================
|
||||
|
||||
func TestLighterStopLossOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping stop-loss test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if we have a position first
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Skip("No ETH position to set stop-loss for")
|
||||
}
|
||||
|
||||
// Calculate stop-loss price (5% below entry for long, 5% above for short)
|
||||
var stopPrice float64
|
||||
if pos.Side == "long" {
|
||||
stopPrice = pos.EntryPrice * 0.95
|
||||
} else {
|
||||
stopPrice = pos.EntryPrice * 1.05
|
||||
}
|
||||
|
||||
t.Logf("Position: %s %s, size=%.4f, entry=%.2f", pos.Symbol, pos.Side, pos.Size, pos.EntryPrice)
|
||||
t.Logf("Setting stop-loss at %.2f", stopPrice)
|
||||
|
||||
err = trader.SetStopLoss("ETH", strings.ToUpper(pos.Side), pos.Size, stopPrice)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetStopLoss failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetStopLoss succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterTakeProfitOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping take-profit test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if we have a position first
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Skip("No ETH position to set take-profit for")
|
||||
}
|
||||
|
||||
// Calculate take-profit price (10% above entry for long, 10% below for short)
|
||||
var takeProfitPrice float64
|
||||
if pos.Side == "long" {
|
||||
takeProfitPrice = pos.EntryPrice * 1.10
|
||||
} else {
|
||||
takeProfitPrice = pos.EntryPrice * 0.90
|
||||
}
|
||||
|
||||
t.Logf("Position: %s %s, size=%.4f, entry=%.2f", pos.Symbol, pos.Side, pos.Size, pos.EntryPrice)
|
||||
t.Logf("Setting take-profit at %.2f", takeProfitPrice)
|
||||
|
||||
err = trader.SetTakeProfit("ETH", strings.ToUpper(pos.Side), pos.Size, takeProfitPrice)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetTakeProfit failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetTakeProfit succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Full Trading Flow Tests ====================
|
||||
|
||||
func TestLighterFullTradingFlow(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping full trading flow test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
symbol := "ETH"
|
||||
quantity := 0.01 // Minimum quantity
|
||||
leverage := 10
|
||||
|
||||
// Step 1: Get initial state
|
||||
t.Log("=== Step 1: Get Initial State ===")
|
||||
balance, _ := trader.GetBalance()
|
||||
if equity, ok := balance["total_equity"].(float64); ok {
|
||||
t.Logf(" Initial equity: %.2f", equity)
|
||||
}
|
||||
|
||||
marketPrice, err := trader.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
t.Logf(" Market price: %.2f", marketPrice)
|
||||
|
||||
// Step 2: Set leverage
|
||||
t.Log("=== Step 2: Set Leverage ===")
|
||||
err = trader.SetLeverage(symbol, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("SetLeverage failed: %v", err)
|
||||
}
|
||||
t.Logf(" Leverage set to %dx", leverage)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 3: Open Long Position
|
||||
t.Log("=== Step 3: Open Long Position ===")
|
||||
result, err := trader.OpenLong(symbol, quantity, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLong failed: %v", err)
|
||||
}
|
||||
t.Logf(" OpenLong result: %v", result)
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Step 4: Verify position
|
||||
t.Log("=== Step 4: Verify Position ===")
|
||||
pos, err := trader.GetPosition(symbol)
|
||||
if err != nil {
|
||||
t.Errorf("GetPosition failed: %v", err)
|
||||
} else if pos != nil {
|
||||
t.Logf(" Position: %s %s, size=%.4f, entry=%.2f, pnl=%.2f",
|
||||
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.UnrealizedPnL)
|
||||
}
|
||||
|
||||
// Step 5: Place limit order (sell at higher price)
|
||||
t.Log("=== Step 5: Place Limit Sell Order ===")
|
||||
limitPrice := marketPrice * 1.05 // 5% above market
|
||||
limitResult, err := trader.CreateOrder(symbol, true, quantity, limitPrice, "limit", true)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to place limit order: %v", err)
|
||||
} else {
|
||||
t.Logf(" Limit order placed: %v", limitResult)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 6: Get open orders
|
||||
t.Log("=== Step 6: Get Open Orders ===")
|
||||
orders, err := trader.GetOpenOrders(symbol)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to get open orders: %v", err)
|
||||
} else {
|
||||
t.Logf(" Open orders: %d", len(orders))
|
||||
for _, o := range orders {
|
||||
t.Logf(" - %s %s: qty=%.4f @ %.2f", o.Side, o.Type, o.Quantity, o.Price)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Cancel all orders
|
||||
t.Log("=== Step 7: Cancel All Orders ===")
|
||||
err = trader.CancelAllOrders(symbol)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to cancel orders: %v", err)
|
||||
} else {
|
||||
t.Log(" All orders cancelled")
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 8: Close position
|
||||
t.Log("=== Step 8: Close Position ===")
|
||||
closeResult, err := trader.CloseLong(symbol, 0) // 0 = close all
|
||||
if err != nil {
|
||||
t.Errorf("CloseLong failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" CloseLong result: %v", closeResult)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Step 9: Verify position closed
|
||||
t.Log("=== Step 9: Verify Position Closed ===")
|
||||
pos, _ = trader.GetPosition(symbol)
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Log(" ✅ Position closed successfully")
|
||||
} else {
|
||||
t.Logf(" ⚠️ Position still exists: size=%.4f", pos.Size)
|
||||
}
|
||||
|
||||
// Step 10: Get final balance
|
||||
t.Log("=== Step 10: Get Final State ===")
|
||||
balance, _ = trader.GetBalance()
|
||||
if equity, ok := balance["total_equity"].(float64); ok {
|
||||
t.Logf(" Final equity: %.2f", equity)
|
||||
}
|
||||
|
||||
t.Log("=== Full Trading Flow Completed ===")
|
||||
}
|
||||
|
||||
// ==================== API Key Validation Tests ====================
|
||||
|
||||
func TestLighterAPIKeyValid(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if API key is valid
|
||||
if trader.apiKeyValid {
|
||||
t.Log("✅ API key is VALID and matches server")
|
||||
} else {
|
||||
t.Error("❌ API key is INVALID - does not match server")
|
||||
}
|
||||
|
||||
// Verify by checking the actual API key
|
||||
err := trader.checkClient()
|
||||
if err != nil {
|
||||
t.Errorf("API key verification error: %v", err)
|
||||
} else {
|
||||
t.Log("✅ API key verification passed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Market Order Tests ====================
|
||||
|
||||
func TestLighterMarketOrderBuy(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping market order test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Create a small market buy order
|
||||
quantity := 0.01
|
||||
t.Logf("Creating market buy order: %.4f ETH", quantity)
|
||||
|
||||
result, err := trader.CreateOrder("ETH", false, quantity, 0, "market", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("Market buy failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Market buy result: %v", result)
|
||||
|
||||
// Wait and close
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Close the position
|
||||
_, err = trader.CloseLong("ETH", quantity)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to close position: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Position closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterMarketOrderSell(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping market order test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Create a small market sell order (short)
|
||||
quantity := 0.01
|
||||
t.Logf("Creating market sell order (short): %.4f ETH", quantity)
|
||||
|
||||
result, err := trader.CreateOrder("ETH", true, quantity, 0, "market", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("Market sell failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Market sell result: %v", result)
|
||||
|
||||
// Wait and close
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Close the position
|
||||
_, err = trader.CloseShort("ETH", quantity)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to close position: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Position closed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetPosition Tests ====================
|
||||
|
||||
func TestLighterGetPosition(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetPosition for ETH
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil {
|
||||
t.Log("✅ No ETH position (pos is nil)")
|
||||
} else if pos.Size == 0 {
|
||||
t.Log("✅ No ETH position (size is 0)")
|
||||
} else {
|
||||
t.Logf("✅ ETH position found:")
|
||||
t.Logf(" Symbol: %s", pos.Symbol)
|
||||
t.Logf(" Side: %s", pos.Side)
|
||||
t.Logf(" Size: %.4f", pos.Size)
|
||||
t.Logf(" Entry Price: %.2f", pos.EntryPrice)
|
||||
t.Logf(" Mark Price: %.2f", pos.MarkPrice)
|
||||
t.Logf(" Liquidation Price: %.2f", pos.LiquidationPrice)
|
||||
t.Logf(" Unrealized PnL: %.2f", pos.UnrealizedPnL)
|
||||
t.Logf(" Leverage: %.1fx", pos.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Symbol Normalization Tests ====================
|
||||
|
||||
func TestLighterSymbolNormalization(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test different symbol formats
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"ETH", "ETH"},
|
||||
{"ETH-PERP", "ETH"},
|
||||
{"ETHUSDT", "ETH"},
|
||||
{"ETH/USDT", "ETH"},
|
||||
{"BTC", "BTC"},
|
||||
{"BTCUSDT", "BTC"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
// Try to get market price with different formats
|
||||
price, err := trader.GetMarketPrice(tc.input)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ GetMarketPrice(%s) failed: %v", tc.input, err)
|
||||
} else {
|
||||
t.Logf("✅ GetMarketPrice(%s) = %.2f", tc.input, price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -70,7 +70,8 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
}
|
||||
}
|
||||
|
||||
// Create order record
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -87,9 +88,9 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.Time,
|
||||
CreatedAt: trade.Time,
|
||||
UpdatedAt: trade.Time,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -98,7 +99,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -115,7 +116,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -127,7 +128,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -74,6 +74,7 @@ type LighterTraderV2 struct {
|
||||
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
||||
apiKeyIndex uint8 // API Key index (default 0)
|
||||
accountIndex int64 // Account index
|
||||
apiKeyValid bool // Whether API key has been validated against server
|
||||
|
||||
// Authentication token
|
||||
authToken string
|
||||
@@ -85,8 +86,10 @@ type LighterTraderV2 struct {
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketListCache []MarketInfo // Cached market list
|
||||
marketListCacheTime time.Time // Time when cache was populated
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
@@ -127,9 +130,6 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Disable proxy for direct connection to Lighter API
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
@@ -162,14 +162,18 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
logger.Warnf("⚠️ The API key may not be registered on-chain. Authenticated API calls (like GetTrades) will fail.")
|
||||
logger.Warnf("⚠️ To fix: Register this API key using change_api_key transaction from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
trader.apiKeyValid = false
|
||||
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
||||
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
||||
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
||||
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
||||
} else {
|
||||
trader.apiKeyValid = true
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet)
|
||||
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
@@ -212,7 +216,7 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
}
|
||||
|
||||
// Log raw response for debugging
|
||||
logger.Infof("LIGHTER account API response: %s", string(body))
|
||||
logger.Debugf("LIGHTER account API response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
@@ -238,10 +242,10 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Log all found accounts
|
||||
logger.Infof("Found %d accounts (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
// Log account summary
|
||||
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
for i, acc := range allAccounts {
|
||||
logger.Infof(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
}
|
||||
|
||||
account := &allAccounts[0]
|
||||
@@ -253,26 +257,79 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ApiKeyResponse API key query response
|
||||
type ApiKeyResponse struct {
|
||||
Code int `json:"code"`
|
||||
ApiKeys []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
ApiKeyIndex uint8 `json:"api_key_index"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"api_keys"`
|
||||
}
|
||||
|
||||
// getApiKeyFromServer Get API Key public key from Lighter server
|
||||
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
||||
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
||||
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ApiKeyResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return "", fmt.Errorf("API error (code %d)", result.Code)
|
||||
}
|
||||
|
||||
if len(result.ApiKeys) == 0 {
|
||||
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
||||
}
|
||||
|
||||
return result.ApiKeys[0].PublicKey, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
func (t *LighterTraderV2) checkClient() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get API Key public key registered on server
|
||||
publicKey, err := t.httpClient.GetApiKey(t.accountIndex, t.apiKeyIndex)
|
||||
// Get API Key public key registered on server (using our own HTTP client)
|
||||
serverPubKey, err := t.getApiKeyFromServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API Key: %w", err)
|
||||
}
|
||||
|
||||
// Get local API Key public key
|
||||
// Get local API Key public key from SDK
|
||||
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
||||
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
||||
localPubKey = strings.Replace(localPubKey, "0x", "", 1)
|
||||
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
||||
|
||||
// Compare public keys
|
||||
if publicKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, publicKey)
|
||||
if serverPubKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
||||
}
|
||||
|
||||
logger.Infof("✓ API Key verification passed")
|
||||
@@ -436,12 +493,8 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response (first 500 chars)
|
||||
logBody := string(body)
|
||||
if len(logBody) > 500 {
|
||||
logBody = logBody[:500] + "..."
|
||||
}
|
||||
logger.Infof("📋 Lighter trades API raw response: %s", logBody)
|
||||
// Debug: log raw response
|
||||
logger.Debugf("Lighter trades API response: %s", string(body))
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
@@ -537,7 +590,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
// - signChanged with position flip: split into close + open
|
||||
|
||||
const EPSILON = 0.0001
|
||||
tradeTime := time.UnixMilli(lt.Timestamp)
|
||||
tradeTime := time.UnixMilli(lt.Timestamp).UTC()
|
||||
|
||||
// Calculate position after trade
|
||||
var posAfter float64
|
||||
@@ -628,7 +681,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
Quantity: qty,
|
||||
RealizedPnL: 0, // Not available in API
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(lt.Timestamp),
|
||||
Time: time.UnixMilli(lt.Timestamp).UTC(),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
// Supports both main accounts and sub-accounts
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
@@ -34,20 +35,47 @@ func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts" field
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
// Check for API error code
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
// Combine both accounts and sub_accounts - some users have sub-accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Find the account that matches our stored accountIndex, or use the first one
|
||||
var account *AccountInfo
|
||||
for i := range allAccounts {
|
||||
acc := &allAccounts[i]
|
||||
// Use index field if account_index is 0
|
||||
if acc.AccountIndex == 0 && acc.Index != 0 {
|
||||
acc.AccountIndex = acc.Index
|
||||
}
|
||||
// Match by stored accountIndex if we have one
|
||||
if t.accountIndex != 0 && acc.AccountIndex == t.accountIndex {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, use the first account
|
||||
if account == nil {
|
||||
account = &allAccounts[0]
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
@@ -328,12 +356,13 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// GetOrderBook Get order book with best bid/ask prices
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64, err error) {
|
||||
// GetOrderBook Get order book (implements GridTrader interface)
|
||||
// Returns bids and asks as [][]float64 where each element is [price, quantity]
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Get order book from Lighter API
|
||||
@@ -341,22 +370,22 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
return nil, nil, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
@@ -369,35 +398,61 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return 0, 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
return nil, nil, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Get best bid (highest buy price)
|
||||
if len(apiResp.Data.Bids) > 0 && len(apiResp.Data.Bids[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Bids[0][0].(float64); ok {
|
||||
bestBid = price
|
||||
} else if priceStr, ok := apiResp.Data.Bids[0][0].(string); ok {
|
||||
bestBid, _ = strconv.ParseFloat(priceStr, 64)
|
||||
// Helper to parse price/quantity from interface{}
|
||||
parseFloat := func(v interface{}) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert bids to [][]float64
|
||||
maxBids := len(apiResp.Data.Bids)
|
||||
if depth > 0 && depth < maxBids {
|
||||
maxBids = depth
|
||||
}
|
||||
bids = make([][]float64, 0, maxBids)
|
||||
for i := 0; i < maxBids; i++ {
|
||||
if len(apiResp.Data.Bids[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Bids[i][0])
|
||||
qty := parseFloat(apiResp.Data.Bids[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get best ask (lowest sell price)
|
||||
if len(apiResp.Data.Asks) > 0 && len(apiResp.Data.Asks[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Asks[0][0].(float64); ok {
|
||||
bestAsk = price
|
||||
} else if priceStr, ok := apiResp.Data.Asks[0][0].(string); ok {
|
||||
bestAsk, _ = strconv.ParseFloat(priceStr, 64)
|
||||
// Convert asks to [][]float64
|
||||
maxAsks := len(apiResp.Data.Asks)
|
||||
if depth > 0 && depth < maxAsks {
|
||||
maxAsks = depth
|
||||
}
|
||||
asks = make([][]float64, 0, maxAsks)
|
||||
for i := 0; i < maxAsks; i++ {
|
||||
if len(apiResp.Data.Asks[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Asks[i][0])
|
||||
qty := parseFloat(apiResp.Data.Asks[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestBid <= 0 || bestAsk <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
logger.Infof("✓ Lighter order book: %s best_bid=%.2f, best_ask=%.2f, depth=%d/%d",
|
||||
symbol, bids[0][0], asks[0][0], len(bids), len(asks))
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
|
||||
return bestBid, bestAsk, nil
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
@@ -100,15 +99,18 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s?auth=%s", t.baseURL, orderID, encodedAuth)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -148,7 +150,7 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
"orderId": order.OrderID,
|
||||
"status": unifiedStatus,
|
||||
"avgPrice": order.Price,
|
||||
"executedQty": order.FilledQty,
|
||||
"executedQty": order.FilledBaseAmount,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
@@ -210,9 +212,15 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d",
|
||||
t.baseURL, t.accountIndex, marketIndex)
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d&auth=%s",
|
||||
t.baseURL, t.accountIndex, marketIndex, encodedAuth)
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders: endpoint=%s", endpoint[:min(len(endpoint), 120)]+"...")
|
||||
|
||||
// Send GET request
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -220,8 +228,6 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -235,11 +241,13 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders raw response: %s", string(body))
|
||||
|
||||
// Parse response - Lighter API uses "orders" field, not "data"
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []OrderResponse `json:"data"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
@@ -250,11 +258,15 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get active orders (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Data))
|
||||
return apiResp.Data, nil
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
logger.Debugf(" Order[%d]: order_id=%s, order_index=%d, market=%d", i, order.OrderID, order.OrderIndex, order.MarketIndex)
|
||||
}
|
||||
return apiResp.Orders, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel a single order
|
||||
// orderID can be either a numeric order_index or a tx_hash string
|
||||
func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
@@ -267,10 +279,15 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Convert orderID to int64
|
||||
// Try to parse orderID as numeric order_index first
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
// orderID is a tx_hash, need to query order to get numeric order_index
|
||||
logger.Debugf("📋 LIGHTER CancelOrder: orderID is tx_hash, querying order...")
|
||||
orderIndex, err = t.getOrderIndexByTxHash(symbol, orderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get order index from tx_hash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build cancel order request
|
||||
@@ -280,22 +297,26 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCancelOrderTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign cancel order: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := json.Marshal(tx)
|
||||
// Get tx_info from SDK (consistent with CreateOrder and other transactions)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit cancel order to LIGHTER API
|
||||
_, err = t.submitCancelOrder(txBytes)
|
||||
// Submit cancel order to LIGHTER API using unified submitOrder function
|
||||
_, err = t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit cancel order: %w", err)
|
||||
}
|
||||
@@ -304,65 +325,21 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CANCEL_ORDER = 15
|
||||
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(TX_TYPE_CANCEL_ORDER)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", string(signedTx)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
// getOrderIndexByTxHash finds the numeric order_index by searching active orders for the tx_hash
|
||||
func (t *LighterTraderV2) getOrderIndexByTxHash(symbol, txHash string) (int64, error) {
|
||||
// Get all active orders for this symbol
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Search for the order with matching tx_hash (order_id)
|
||||
for _, order := range orders {
|
||||
if order.OrderID == txHash {
|
||||
logger.Debugf("📋 LIGHTER Found order_index %d for tx_hash %s", order.OrderIndex, txHash)
|
||||
return order.OrderIndex, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to submit cancel order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": sendResp.Data["tx_hash"],
|
||||
"status": "cancelled",
|
||||
}
|
||||
|
||||
logger.Infof("✓ Cancel order submitted to LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"])
|
||||
return result, nil
|
||||
return 0, fmt.Errorf("order not found with tx_hash: %s (may already be filled or cancelled)", txHash)
|
||||
}
|
||||
|
||||
421
trader/lighter_trader_v2_orders_test.go
Normal file
421
trader/lighter_trader_v2_orders_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetActiveOrders_ParseResponse tests parsing of Lighter API response
|
||||
func TestGetActiveOrders_ParseResponse(t *testing.T) {
|
||||
// Mock response from Lighter API
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
},
|
||||
{
|
||||
"order_id": "123457",
|
||||
"order_index": 123457,
|
||||
"market_index": 0,
|
||||
"side": "bid",
|
||||
"type": "limit",
|
||||
"is_ask": false,
|
||||
"price": "3100.00",
|
||||
"initial_base_amount": "2.0",
|
||||
"remaining_base_amount": "2.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745601000,
|
||||
"created_at": 1736745601000
|
||||
},
|
||||
{
|
||||
"order_id": "123458",
|
||||
"order_index": 123458,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "stop_loss",
|
||||
"is_ask": true,
|
||||
"price": "0",
|
||||
"initial_base_amount": "1.0",
|
||||
"remaining_base_amount": "1.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "3000.00",
|
||||
"reduce_only": true,
|
||||
"timestamp": 1736745602000,
|
||||
"created_at": 1736745602000
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Parse the response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err, "Should parse response without error")
|
||||
|
||||
// Verify parsed data
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 3, len(apiResp.Orders))
|
||||
|
||||
// Test first order (sell limit)
|
||||
order1 := apiResp.Orders[0]
|
||||
assert.Equal(t, "123456", order1.OrderID)
|
||||
assert.True(t, order1.IsAsk, "First order should be ask (sell)")
|
||||
assert.Equal(t, "3150.50", order1.Price)
|
||||
assert.Equal(t, "1.5", order1.RemainingBaseAmount)
|
||||
assert.False(t, order1.ReduceOnly)
|
||||
|
||||
// Test second order (buy limit)
|
||||
order2 := apiResp.Orders[1]
|
||||
assert.Equal(t, "123457", order2.OrderID)
|
||||
assert.False(t, order2.IsAsk, "Second order should be bid (buy)")
|
||||
assert.Equal(t, "3100.00", order2.Price)
|
||||
|
||||
// Test third order (stop-loss)
|
||||
order3 := apiResp.Orders[2]
|
||||
assert.Equal(t, "123458", order3.OrderID)
|
||||
assert.Equal(t, "stop_loss", order3.Type)
|
||||
assert.Equal(t, "3000.00", order3.TriggerPrice)
|
||||
assert.True(t, order3.ReduceOnly)
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_EmptyResponse tests handling of empty orders
|
||||
func TestGetActiveOrders_EmptyResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 0, len(apiResp.Orders))
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_ErrorResponse tests handling of API error
|
||||
func TestGetActiveOrders_ErrorResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature"
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 29500, apiResp.Code)
|
||||
assert.Contains(t, apiResp.Message, "invalid signature")
|
||||
}
|
||||
|
||||
// TestConvertOrderResponseToOpenOrder tests conversion logic
|
||||
func TestConvertOrderResponseToOpenOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
order OrderResponse
|
||||
expectedSide string
|
||||
expectedType string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Sell limit order (opening short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "1",
|
||||
IsAsk: true,
|
||||
Type: "limit",
|
||||
Price: "3150.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Buy limit order (opening long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "2",
|
||||
IsAsk: false,
|
||||
Type: "limit",
|
||||
Price: "3100.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Sell stop-loss (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "3",
|
||||
IsAsk: true,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3000.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Buy stop-loss (closing short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "4",
|
||||
IsAsk: false,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3200.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Take profit (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "5",
|
||||
IsAsk: true,
|
||||
Type: "take_profit",
|
||||
TriggerPrice: "3500.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "TAKE_PROFIT_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Convert side
|
||||
side := "BUY"
|
||||
if tc.order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
assert.Equal(t, tc.expectedSide, side)
|
||||
|
||||
// Convert order type
|
||||
orderType := "LIMIT"
|
||||
if tc.order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if tc.order.Type == "stop_loss" || tc.order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if tc.order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
assert.Equal(t, tc.expectedType, orderType)
|
||||
|
||||
// Convert position side
|
||||
positionSide := "LONG"
|
||||
if tc.order.ReduceOnly {
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT"
|
||||
} else {
|
||||
positionSide = "LONG"
|
||||
}
|
||||
} else {
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.expectedPosSide, positionSide)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_MockServer tests the full HTTP flow with a mock server
|
||||
func TestGetActiveOrders_MockServer(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request path and auth parameter
|
||||
assert.Contains(t, r.URL.Path, "/api/v1/accountActiveOrders")
|
||||
|
||||
// Check that auth query parameter is present
|
||||
authParam := r.URL.Query().Get("auth")
|
||||
if authParam == "" {
|
||||
// Return error if no auth parameter
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []map[string]interface{}{
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test request without auth - should fail
|
||||
resp, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var errorResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
assert.Equal(t, 29500, errorResp.Code)
|
||||
|
||||
// Test request with auth - should succeed
|
||||
resp2, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0&auth=test_token")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
var successResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&successResp)
|
||||
assert.Equal(t, 200, successResp.Code)
|
||||
assert.Equal(t, 1, len(successResp.Orders))
|
||||
}
|
||||
|
||||
// TestAuthTokenFormat tests the auth token format
|
||||
func TestAuthTokenFormat(t *testing.T) {
|
||||
// Auth token format: timestamp:account_index:api_key_index:signature
|
||||
// Example: 1768308847:687247:0:742e02...
|
||||
|
||||
sampleToken := "1768308847:687247:0:742e02abc123"
|
||||
|
||||
// The token should be URL encoded when used as query parameter
|
||||
// Colons become %3A
|
||||
expectedEncoded := "1768308847%3A687247%3A0%3A742e02abc123"
|
||||
|
||||
// URL encode the token
|
||||
encoded := url.QueryEscape(sampleToken)
|
||||
|
||||
assert.Equal(t, expectedEncoded, encoded)
|
||||
}
|
||||
|
||||
// TestOrderResponseStruct tests that OrderResponse struct matches API response
|
||||
func TestOrderResponseStruct(t *testing.T) {
|
||||
// Real API response sample (from logs)
|
||||
realResponse := `{
|
||||
"order_id": "4609885",
|
||||
"order_index": 4609885,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.00",
|
||||
"initial_base_amount": "0.0300",
|
||||
"remaining_base_amount": "0.0300",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
}`
|
||||
|
||||
var order OrderResponse
|
||||
err := json.Unmarshal([]byte(realResponse), &order)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "4609885", order.OrderID)
|
||||
assert.Equal(t, int64(4609885), order.OrderIndex)
|
||||
assert.Equal(t, 0, order.MarketIndex)
|
||||
assert.Equal(t, "ask", order.Side)
|
||||
assert.Equal(t, "limit", order.Type)
|
||||
assert.True(t, order.IsAsk)
|
||||
assert.Equal(t, "3150.00", order.Price)
|
||||
assert.Equal(t, "0.0300", order.InitialBaseAmount)
|
||||
assert.Equal(t, "0.0300", order.RemainingBaseAmount)
|
||||
assert.Equal(t, "0", order.FilledBaseAmount)
|
||||
assert.Equal(t, "open", order.Status)
|
||||
assert.Equal(t, "", order.TriggerPrice)
|
||||
assert.False(t, order.ReduceOnly)
|
||||
assert.Equal(t, int64(1736745600000), order.Timestamp)
|
||||
assert.Equal(t, int64(1736745600000), order.CreatedAt)
|
||||
}
|
||||
|
||||
// BenchmarkParseOrderResponse benchmarks response parsing
|
||||
func BenchmarkParseOrderResponse(b *testing.B) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{"order_id": "1", "is_ask": true, "price": "3150.50", "remaining_base_amount": "1.5"},
|
||||
{"order_id": "2", "is_ask": false, "price": "3100.00", "remaining_base_amount": "2.0"},
|
||||
{"order_id": "3", "is_ask": true, "price": "3200.00", "remaining_base_amount": "0.5"}
|
||||
]
|
||||
}`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
}
|
||||
}
|
||||
@@ -273,9 +273,13 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign order: %w", err)
|
||||
@@ -288,7 +292,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Infof("DEBUG tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
logger.Debugf("tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -302,6 +306,16 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
logger.Infof("✓ LIGHTER order created: %s %s qty=%.4f", symbol, side, quantity)
|
||||
|
||||
// For limit orders, poll for the actual order_index after submission
|
||||
// This is needed because CancelOrder requires the numeric order_index, not tx_hash
|
||||
if orderType == "limit" {
|
||||
txHash, _ := orderResp["tx_hash"].(string)
|
||||
if orderIndex, err := t.pollForOrderIndex(symbol, txHash); err == nil && orderIndex > 0 {
|
||||
orderResp["orderId"] = fmt.Sprintf("%d", orderIndex)
|
||||
orderResp["order_index"] = orderIndex
|
||||
}
|
||||
}
|
||||
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
@@ -386,10 +400,19 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
|
||||
// Log full response for debugging
|
||||
logger.Infof("DEBUG API response: %s", string(respBody))
|
||||
logger.Debugf("API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
// Provide more specific error message for signature errors
|
||||
// Code 21120: invalid signature (order submission)
|
||||
// Code 29500: internal server error: invalid signature (authenticated GET APIs)
|
||||
if (sendResp.Code == 21120 || sendResp.Code == 29500) && strings.Contains(sendResp.Message, "invalid signature") {
|
||||
if !t.apiKeyValid {
|
||||
return nil, fmt.Errorf("API Key MISMATCH (code %d): The API key stored in NOFX does not match the one registered on Lighter. Please update your Lighter API key in Exchange settings at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("API Key signature invalid (code %d): Please verify your Lighter API Key in Exchange settings matches the key registered at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to submit order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
@@ -403,17 +426,45 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId
|
||||
"orderId": txHash, // Use tx_hash as orderId initially
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pollForOrderIndex polls active orders to find the order_index for a newly created order
|
||||
// Returns the highest order_index (newest order) for the given symbol
|
||||
func (t *LighterTraderV2) pollForOrderIndex(symbol string, txHash string) (int64, error) {
|
||||
// Wait a moment for the order to be processed
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return 0, fmt.Errorf("no active orders found (order may have been filled immediately)")
|
||||
}
|
||||
|
||||
// Find the highest order_index (newest order)
|
||||
var highestIndex int64
|
||||
for _, order := range orders {
|
||||
if order.OrderIndex > highestIndex {
|
||||
highestIndex = order.OrderIndex
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order created with order_index: %d (tx_hash: %s)", highestIndex, txHash)
|
||||
return highestIndex, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol Convert NOFX symbol format to Lighter format
|
||||
// NOFX uses "BTC-PERP", "BTCUSDT", etc. Lighter uses "BTC", "ETH", etc.
|
||||
func normalizeSymbol(symbol string) string {
|
||||
@@ -431,7 +482,7 @@ func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// 1. Fetch market list from API (TODO: cache this)
|
||||
// Fetch market list from API (cached for 1 hour)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||
@@ -467,8 +518,18 @@ type MarketInfo struct {
|
||||
PriceDecimals int `json:"price_decimals"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API
|
||||
// fetchMarketList Fetch market list from API with caching (TTL: 1 hour)
|
||||
func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
// Check cache (TTL: 1 hour)
|
||||
t.marketMutex.RLock()
|
||||
if len(t.marketListCache) > 0 && time.Since(t.marketListCacheTime) < time.Hour {
|
||||
cached := t.marketListCache
|
||||
t.marketMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBooks", t.baseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -514,14 +575,20 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
for _, market := range apiResp.OrderBooks {
|
||||
if market.Status == "active" {
|
||||
markets = append(markets, MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.marketMutex.Lock()
|
||||
t.marketListCache = markets
|
||||
t.marketListCacheTime = time.Now()
|
||||
t.marketMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
@@ -550,31 +617,132 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint16, error)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
// Lighter uses InitialMarginFraction to represent leverage:
|
||||
// - InitialMarginFraction = (100 / leverage) * 100 (stored as percentage * 100)
|
||||
// - e.g., 5x leverage = 20% margin = 2000 in API
|
||||
// - e.g., 20x leverage = 5% margin = 500 in API
|
||||
func (t *LighterTraderV2) SetLeverage(symbol string, leverage int) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// TODO: Sign and submit SetLeverage transaction using SDK
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx", symbol, leverage)
|
||||
// Validate leverage range (1x to 50x typical max)
|
||||
if leverage < 1 || leverage > 50 {
|
||||
return fmt.Errorf("leverage must be between 1 and 50, got %d", leverage)
|
||||
}
|
||||
|
||||
return nil // Return success for now
|
||||
// Get market info (includes market_id)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Calculate InitialMarginFraction from leverage
|
||||
// leverage = 100 / margin_fraction_percent
|
||||
// margin_fraction_percent = 100 / leverage
|
||||
// API value = margin_fraction_percent * 100
|
||||
marginFractionPercent := 100.0 / float64(leverage)
|
||||
initialMarginFraction := uint16(marginFractionPercent * 100) // e.g., 5x => 20% => 2000
|
||||
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx (margin_fraction=%.2f%%, API value=%d)",
|
||||
symbol, leverage, marginFractionPercent, initialMarginFraction)
|
||||
|
||||
// Build UpdateLeverage request
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: 0, // 0 = cross margin (default)
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
nonce := int64(-1) // Auto-fetch nonce
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API (reuse submitOrder which handles any transaction type)
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Leverage set successfully: %s = %dx (tx_hash: %v)", symbol, leverage, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode Set margin mode (implements Trader interface)
|
||||
// Lighter uses UpdateLeverage transaction which includes both leverage and margin mode
|
||||
// MarginMode: 0 = cross, 1 = isolated
|
||||
func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
modeStr := "isolated"
|
||||
if isCrossMargin {
|
||||
modeStr = "cross"
|
||||
// Get market info
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Determine margin mode value
|
||||
var marginMode uint8 = 0 // cross
|
||||
modeStr := "cross"
|
||||
if !isCrossMargin {
|
||||
marginMode = 1 // isolated
|
||||
modeStr = "isolated"
|
||||
}
|
||||
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s", symbol, modeStr)
|
||||
// Get current position to preserve leverage, or use default 10x if no position
|
||||
var initialMarginFraction uint16 = 1000 // Default 10x leverage (10% margin = 1000)
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err == nil && pos != nil && pos.Leverage > 0 {
|
||||
// Calculate InitialMarginFraction from current leverage
|
||||
marginFractionPercent := 100.0 / pos.Leverage
|
||||
initialMarginFraction = uint16(marginFractionPercent * 100)
|
||||
}
|
||||
|
||||
// TODO: Sign and submit SetMarginMode transaction using SDK
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s (margin_mode=%d, preserving leverage)", symbol, modeStr, marginMode)
|
||||
|
||||
// Build UpdateLeverage request (also updates margin mode)
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: marginMode,
|
||||
}
|
||||
|
||||
// Sign transaction
|
||||
nonce := int64(-1)
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Margin mode set successfully: %s = %s (tx_hash: %v)", symbol, modeStr, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -653,7 +821,7 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("DEBUG stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
logger.Debugf("stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
|
||||
// Submit order
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -686,3 +854,120 @@ func pow10(n int) int64 {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// Get active orders from Lighter API
|
||||
activeOrders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range activeOrders {
|
||||
// Convert side: Lighter uses is_ask (true=sell, false=buy)
|
||||
side := "BUY"
|
||||
if order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Determine order type from Lighter's type field
|
||||
orderType := "LIMIT"
|
||||
if order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if order.Type == "stop_loss" || order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Determine position side based on order direction and reduce-only flag
|
||||
positionSide := "LONG"
|
||||
if order.ReduceOnly {
|
||||
// For reduce-only orders, position side is opposite to order side
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT" // Buying to close short
|
||||
} else {
|
||||
positionSide = "LONG" // Selling to close long
|
||||
}
|
||||
} else {
|
||||
// For opening orders
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse price and quantity from string fields
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.RemainingBaseAmount, 64)
|
||||
if quantity == 0 {
|
||||
quantity, _ = strconv.ParseFloat(order.InitialBaseAmount, 64)
|
||||
}
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
|
||||
openOrder := OpenOrder{
|
||||
OrderID: order.OrderID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: price,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
}
|
||||
result = append(result, openOrder)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder implements GridTrader interface for grid trading
|
||||
// Places a limit order at the specified price
|
||||
func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Determine if this is a sell (ask) order
|
||||
isAsk := req.Side == "SELL"
|
||||
|
||||
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f, leverage=%dx",
|
||||
req.Symbol, req.Side, req.Price, req.Quantity, req.Leverage)
|
||||
|
||||
// Set leverage before placing order (important for grid trading)
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("⚠️ Failed to set leverage: %v (continuing with current leverage)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limit order using existing CreateOrder function
|
||||
orderResult, err := t.CreateOrder(req.Symbol, isAsk, req.Quantity, req.Price, "limit", req.ReduceOnly)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID from result
|
||||
orderID := ""
|
||||
if id, ok := orderResult["orderId"]; ok {
|
||||
orderID = fmt.Sprintf("%v", id)
|
||||
} else if txHash, ok := orderResult["tx_hash"]; ok {
|
||||
orderID = fmt.Sprintf("%v", txHash)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
|
||||
req.Symbol, req.Side, req.Price, 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
|
||||
}
|
||||
|
||||
@@ -41,18 +41,24 @@ type CreateOrderRequest struct {
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response (Lighter)
|
||||
// OrderResponse Order response (Lighter API)
|
||||
// Field names must match Lighter API response exactly
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
OrderID string `json:"order_id"`
|
||||
OrderIndex int64 `json:"order_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Side string `json:"side"` // "bid" or "ask"
|
||||
Type string `json:"type"` // "limit", "market", etc.
|
||||
IsAsk bool `json:"is_ask"` // true = sell, false = buy
|
||||
Price string `json:"price"` // Price as string
|
||||
InitialBaseAmount string `json:"initial_base_amount"` // Original quantity
|
||||
RemainingBaseAmount string `json:"remaining_base_amount"` // Remaining quantity
|
||||
FilledBaseAmount string `json:"filled_base_amount"` // Filled quantity
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
TriggerPrice string `json:"trigger_price"` // For stop orders
|
||||
ReduceOnly bool `json:"reduce_only"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
|
||||
@@ -133,7 +133,7 @@ func (t *OKXTrader) GetTrades(startTime time.Time, limit int) ([]OKXTrade, error
|
||||
FillQtyBase: fillQtyBase,
|
||||
Fee: -fee, // OKX returns negative fee
|
||||
FeeAsset: fill.FeeCcy,
|
||||
ExecTime: time.UnixMilli(ts),
|
||||
ExecTime: time.UnixMilli(ts).UTC(),
|
||||
IsMaker: fill.ExecType == "M",
|
||||
OrderType: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
@@ -169,7 +169,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
||||
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
@@ -197,7 +197,8 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
// Normalize side for storage
|
||||
side := strings.ToUpper(trade.Side)
|
||||
|
||||
// Create order record
|
||||
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -214,9 +215,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
FilledQuantity: trade.FillQtyBase,
|
||||
AvgFillPrice: trade.FillPrice,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: trade.ExecTime,
|
||||
CreatedAt: trade.ExecTime,
|
||||
UpdatedAt: trade.ExecTime,
|
||||
FilledAt: execTimeMs,
|
||||
CreatedAt: execTimeMs,
|
||||
UpdatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
@@ -225,7 +226,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record
|
||||
// Create fill record - use UTC time in milliseconds
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
@@ -242,7 +243,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
CommissionAsset: trade.FeeAsset,
|
||||
RealizedPnL: 0, // OKX fills don't include PnL per trade
|
||||
IsMaker: trade.IsMaker,
|
||||
CreatedAt: trade.ExecTime,
|
||||
CreatedAt: execTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
@@ -254,7 +255,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX
|
||||
trade.ExecTime, trade.TradeID,
|
||||
execTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
|
||||
@@ -1366,8 +1366,8 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
|
||||
// Times
|
||||
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||
record.EntryTime = time.UnixMilli(cTime)
|
||||
record.ExitTime = time.UnixMilli(uTime)
|
||||
record.EntryTime = time.UnixMilli(cTime).UTC()
|
||||
record.ExitTime = time.UnixMilli(uTime).UTC()
|
||||
|
||||
// Close type
|
||||
switch pos.Type {
|
||||
@@ -1387,3 +1387,257 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
PosSide string `json:"posSide"` // long/short/net
|
||||
OrdType string `json:"ordType"` // limit/market/post_only
|
||||
Px string `json:"px"` // price
|
||||
Sz string `json:"sz"` // size
|
||||
State string `json:"state"` // live/partially_filled
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Px, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
// Convert OKX side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrdId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrdType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pending algo orders (stop-loss/take-profit)
|
||||
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxAlgoPendingPath, instId)
|
||||
algoData, err := t.doRequest("GET", algoPath, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
|
||||
}
|
||||
if err == nil && algoData != nil {
|
||||
var algoOrders []struct {
|
||||
AlgoId string `json:"algoId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
OrdType string `json:"ordType"` // conditional/oco/trigger
|
||||
TriggerPx string `json:"triggerPx"`
|
||||
Sz string `json:"sz"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
|
||||
for _, order := range algoOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
// Map OKX algo order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.OrdType == "oco" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.AlgoId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ OKX 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 *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
instId := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Get instrument info
|
||||
inst, err := t.getInstrument(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instrument info: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[OKX] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert quantity to contract size
|
||||
sz := req.Quantity / inst.CtVal
|
||||
szStr := t.formatSize(sz, inst)
|
||||
|
||||
// Determine side and position side
|
||||
side := "buy"
|
||||
posSide := "long"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "limit",
|
||||
"sz": szStr,
|
||||
"px": fmt.Sprintf("%.8f", req.Price),
|
||||
"clOrdId": genOkxClOrdID(),
|
||||
"tag": okxTag,
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
|
||||
|
||||
data, err := t.doRequest("POST", okxOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
ClOrdId string `json:"clOrdId"`
|
||||
SCode string `json:"sCode"`
|
||||
SMsg string `json:"sMsg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return nil, fmt.Errorf("empty order response")
|
||||
}
|
||||
|
||||
if orders[0].SCode != "0" {
|
||||
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
instId, side, req.Price, orders[0].OrdId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orders[0].OrdId,
|
||||
ClientID: orders[0].ClOrdId,
|
||||
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 *OKXTrader) CancelOrder(symbol, orderID string) error {
|
||||
instId := t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"ordId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, 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)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result[0].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[0].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
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
|
||||
logger.Infof("📥 Found %d positions on exchange", len(positions))
|
||||
|
||||
// Step 3: Create snapshot record for each position
|
||||
now := time.Now()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
createdCount := 0
|
||||
|
||||
for _, posMap := range positions {
|
||||
@@ -74,18 +74,18 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, now.UnixMilli()),
|
||||
ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, nowMs),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: positionAmt,
|
||||
EntryPrice: entryPrice,
|
||||
EntryOrderID: "snapshot", // Mark as snapshot
|
||||
EntryTime: now,
|
||||
EntryTime: nowMs,
|
||||
Leverage: int(leverage),
|
||||
Status: "OPEN",
|
||||
Source: "snapshot", // Mark source as snapshot
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedAt: nowMs,
|
||||
UpdatedAt: nowMs,
|
||||
}
|
||||
|
||||
if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
// Force HMR Update - Reliability Fix v3 (Emergency Recovery)
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import useSWR from 'swr'
|
||||
import { api } from './lib/api'
|
||||
import { TraderDashboardPage } from './pages/TraderDashboardPage'
|
||||
|
||||
@@ -15,18 +14,17 @@ import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { DebateArenaPage } from './pages/DebateArenaPage'
|
||||
import { StrategyMarketPage } from './pages/StrategyMarketPage'
|
||||
import { DataPage } from './pages/DataPage'
|
||||
import { LoginRequiredOverlay } from './components/LoginRequiredOverlay'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { ConfirmDialogProvider } from './components/ConfirmDialog'
|
||||
import { t, type Language } from './i18n/translations'
|
||||
import { confirmToast, notify } from './lib/notify'
|
||||
import { t } from './i18n/translations'
|
||||
import { useSystemConfig } from './hooks/useSystemConfig'
|
||||
|
||||
import { OFFICIAL_LINKS } from './constants/branding'
|
||||
import { BacktestPage } from './components/BacktestPage'
|
||||
import { LogOut, Loader2 } from 'lucide-react'
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
@@ -44,6 +42,7 @@ type Page =
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
@@ -71,6 +70,7 @@ function App() {
|
||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/strategy-market' || hash === 'strategy-market') return 'strategy-market'
|
||||
if (path === '/data' || hash === 'data') return 'data'
|
||||
if (path === '/debate' || hash === 'debate') return 'debate'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
@@ -91,6 +91,7 @@ function App() {
|
||||
const pathMap: Record<Page, string> = {
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'data': '/data',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
@@ -155,6 +156,8 @@ function App() {
|
||||
setCurrentPage('strategy')
|
||||
} else if (path === '/strategy-market' || hash === 'strategy-market') {
|
||||
setCurrentPage('strategy-market')
|
||||
} else if (path === '/data' || hash === 'data') {
|
||||
setCurrentPage('data')
|
||||
} else if (path === '/debate' || hash === 'debate') {
|
||||
setCurrentPage('debate')
|
||||
} else if (
|
||||
@@ -373,6 +376,51 @@ function App() {
|
||||
if (route === '/reset-password') {
|
||||
return <ResetPasswordPage />
|
||||
}
|
||||
// Data page - publicly accessible with embedded dashboard
|
||||
if (route === '/data') {
|
||||
const dataPageNavigate = (page: Page) => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'data': '/data',
|
||||
'competition': '/competition',
|
||||
'strategy-market': '/strategy-market',
|
||||
'traders': '/traders',
|
||||
'trader': '/dashboard',
|
||||
'backtest': '/backtest',
|
||||
'strategy': '/strategy',
|
||||
'debate': '/debate',
|
||||
'faq': '/faq',
|
||||
}
|
||||
const path = pathMap[page]
|
||||
if (path) {
|
||||
window.location.href = path
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<HeaderBar
|
||||
isLoggedIn={!!user}
|
||||
currentPage="data"
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
user={user}
|
||||
onLogout={logout}
|
||||
onLoginRequired={handleLoginRequired}
|
||||
onPageChange={dataPageNavigate}
|
||||
/>
|
||||
<main className="pt-16">
|
||||
<DataPage />
|
||||
</main>
|
||||
<LoginRequiredOverlay
|
||||
isOpen={loginOverlayOpen}
|
||||
onClose={() => setLoginOverlayOpen(false)}
|
||||
featureName={loginOverlayFeature}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Show landing page for root route
|
||||
if (route === '/' || route === '') {
|
||||
return <LandingPage />
|
||||
@@ -411,6 +459,8 @@ function App() {
|
||||
>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'data' ? (
|
||||
<DataPage />
|
||||
) : currentPage === 'strategy-market' ? (
|
||||
<StrategyMarketPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
|
||||
@@ -31,6 +31,19 @@ interface OrderMarker {
|
||||
symbol: string
|
||||
}
|
||||
|
||||
// 挂单接口定义 (交易所的止盈止损订单)
|
||||
interface OpenOrder {
|
||||
order_id: string
|
||||
symbol: string
|
||||
side: string // BUY/SELL
|
||||
position_side: string // LONG/SHORT
|
||||
type: string // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
|
||||
price: number // 限价单价格
|
||||
stop_price: number // 触发价格 (止损/止盈)
|
||||
quantity: number
|
||||
status: string
|
||||
}
|
||||
|
||||
interface AdvancedChartProps {
|
||||
symbol: string
|
||||
interval?: string
|
||||
@@ -101,6 +114,7 @@ export function AdvancedChart({
|
||||
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||
const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据
|
||||
const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据
|
||||
const priceLinesRef = useRef<any[]>([]) // 存储挂单价格线
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -179,9 +193,15 @@ export function AdvancedChart({
|
||||
return 0
|
||||
}
|
||||
|
||||
// 如果已经是数字(Unix 时间戳),直接返回
|
||||
// 如果已经是数字(Unix 时间戳)
|
||||
if (typeof time === 'number') {
|
||||
console.log('[AdvancedChart] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
// 判断是毫秒还是秒:如果大于 10^12 则认为是毫秒(2001年之后的毫秒时间戳)
|
||||
if (time > 1000000000000) {
|
||||
const seconds = Math.floor(time / 1000)
|
||||
console.log('[AdvancedChart] ✅ Unix timestamp (ms→s):', time, '→', seconds, '(', new Date(time).toISOString(), ')')
|
||||
return seconds
|
||||
}
|
||||
console.log('[AdvancedChart] ✅ Unix timestamp (s):', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
return time
|
||||
}
|
||||
|
||||
@@ -221,8 +241,8 @@ export function AdvancedChart({
|
||||
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||
try {
|
||||
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||
// 获取已成交的订单,限制50条避免标记太多重叠
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||
// 获取已成交的订单,增加到200条以显示更多历史订单
|
||||
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`)
|
||||
|
||||
console.log('[AdvancedChart] Orders API response:', result)
|
||||
|
||||
@@ -301,13 +321,33 @@ export function AdvancedChart({
|
||||
}
|
||||
}
|
||||
|
||||
// 获取交易所挂单 (止盈止损订单)
|
||||
const fetchOpenOrders = async (traderID: string, symbol: string): Promise<OpenOrder[]> => {
|
||||
try {
|
||||
console.log('[AdvancedChart] Fetching open orders for trader:', traderID, 'symbol:', symbol)
|
||||
const result = await httpClient.get(`/api/open-orders?trader_id=${traderID}&symbol=${symbol}`)
|
||||
|
||||
console.log('[AdvancedChart] Open orders API response:', result)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('[AdvancedChart] No open orders found')
|
||||
return []
|
||||
}
|
||||
|
||||
return result.data as OpenOrder[]
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Error fetching open orders:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: height,
|
||||
width: chartContainerRef.current.clientWidth || 800,
|
||||
height: chartContainerRef.current.clientHeight || height,
|
||||
layout: {
|
||||
background: { color: '#0B0E11' },
|
||||
textColor: '#B7BDC6',
|
||||
@@ -407,16 +447,16 @@ export function AdvancedChart({
|
||||
})
|
||||
volumeSeriesRef.current = volumeSeries as any
|
||||
|
||||
// 响应式调整
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chartRef.current) {
|
||||
chartRef.current.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 响应式调整 (ResizeObserver)
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (entries.length === 0 || !entries[0].contentRect) return
|
||||
const { width, height } = entries[0].contentRect
|
||||
chart.applyOptions({ width, height })
|
||||
})
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (chartContainerRef.current) {
|
||||
resizeObserver.observe(chartContainerRef.current)
|
||||
}
|
||||
|
||||
// 监听鼠标移动,显示 OHLC 信息
|
||||
chart.subscribeCrosshairMove((param) => {
|
||||
@@ -450,10 +490,11 @@ export function AdvancedChart({
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver.disconnect()
|
||||
chart.remove()
|
||||
}
|
||||
}, [height])
|
||||
}, []) // Chart is created once, ResizeObserver handles dimension changes
|
||||
|
||||
|
||||
// 加载数据和指标
|
||||
useEffect(() => {
|
||||
@@ -580,15 +621,8 @@ export function AdvancedChart({
|
||||
return klineTimes[left]
|
||||
}
|
||||
|
||||
// 过滤并对齐订单到 K 线时间
|
||||
const markers: Array<{
|
||||
time: Time
|
||||
position: 'belowBar'
|
||||
color: string
|
||||
shape: 'circle'
|
||||
text: string
|
||||
size: number
|
||||
}> = []
|
||||
// 按 K 线时间分组统计订单
|
||||
const ordersByCandle = new Map<number, { buys: number; sells: number }>()
|
||||
|
||||
orders.forEach(order => {
|
||||
// 使用二分查找找到对应的 K 线蜡烛时间
|
||||
@@ -600,15 +634,48 @@ export function AdvancedChart({
|
||||
return
|
||||
}
|
||||
|
||||
const isBuy = order.rawSide === 'buy'
|
||||
markers.push({
|
||||
time: candleTime as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: isBuy ? '#0ECB81' : '#F6465D',
|
||||
shape: 'circle' as const,
|
||||
text: isBuy ? 'B' : 'S',
|
||||
size: 1,
|
||||
})
|
||||
const existing = ordersByCandle.get(candleTime) || { buys: 0, sells: 0 }
|
||||
if (order.rawSide === 'buy') {
|
||||
existing.buys++
|
||||
} else {
|
||||
existing.sells++
|
||||
}
|
||||
ordersByCandle.set(candleTime, existing)
|
||||
})
|
||||
|
||||
// 为每个有订单的 K 线创建标记
|
||||
const markers: Array<{
|
||||
time: Time
|
||||
position: 'belowBar' | 'aboveBar'
|
||||
color: string
|
||||
shape: 'circle'
|
||||
text: string
|
||||
size: number
|
||||
}> = []
|
||||
|
||||
ordersByCandle.forEach((counts, candleTime) => {
|
||||
// 显示买入标记(绿色,在K线下方)
|
||||
if (counts.buys > 0) {
|
||||
markers.push({
|
||||
time: candleTime as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: '#0ECB81',
|
||||
shape: 'circle' as const,
|
||||
text: counts.buys > 1 ? `B${counts.buys}` : 'B',
|
||||
size: 1,
|
||||
})
|
||||
}
|
||||
// 显示卖出标记(红色,在K线上方)
|
||||
if (counts.sells > 0) {
|
||||
markers.push({
|
||||
time: candleTime as Time,
|
||||
position: 'aboveBar' as const,
|
||||
color: '#F6465D',
|
||||
shape: 'circle' as const,
|
||||
text: counts.sells > 1 ? `S${counts.sells}` : 'S',
|
||||
size: 1,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 按时间排序(lightweight-charts 要求标记按时间顺序)
|
||||
@@ -674,6 +741,87 @@ export function AdvancedChart({
|
||||
return () => clearInterval(refreshInterval)
|
||||
}, [symbol, interval, traderID, exchange])
|
||||
|
||||
// 单独刷新挂单价格线 (60秒刷新一次,避免频繁调用交易所API)
|
||||
useEffect(() => {
|
||||
if (!traderID || !candlestickSeriesRef.current) return
|
||||
|
||||
// 加载挂单并显示价格线
|
||||
const loadOpenOrders = async () => {
|
||||
try {
|
||||
// 先清除旧的价格线
|
||||
priceLinesRef.current.forEach(line => {
|
||||
try {
|
||||
candlestickSeriesRef.current?.removePriceLine(line)
|
||||
} catch (e) {
|
||||
// 忽略清除错误
|
||||
}
|
||||
})
|
||||
priceLinesRef.current = []
|
||||
|
||||
const openOrders = await fetchOpenOrders(traderID, symbol)
|
||||
console.log('[AdvancedChart] Open orders for price lines:', openOrders)
|
||||
|
||||
if (openOrders.length > 0 && candlestickSeriesRef.current) {
|
||||
openOrders.forEach(order => {
|
||||
// 获取触发价格 (止损/止盈用 stop_price,限价单用 price)
|
||||
const linePrice = order.stop_price > 0 ? order.stop_price : order.price
|
||||
if (linePrice <= 0) return
|
||||
|
||||
// 判断订单类型
|
||||
const isStopLoss = order.type.includes('STOP') || order.type.includes('SL')
|
||||
const isTakeProfit = order.type.includes('TAKE_PROFIT') || order.type.includes('TP')
|
||||
const isLimit = order.type === 'LIMIT'
|
||||
|
||||
// 设置价格线样式
|
||||
let lineColor = '#F0B90B' // 默认黄色
|
||||
const lineStyle = 2 // 虚线
|
||||
let title = ''
|
||||
|
||||
if (isStopLoss) {
|
||||
lineColor = '#F6465D' // 红色 - 止损
|
||||
title = `SL ${order.quantity}`
|
||||
} else if (isTakeProfit) {
|
||||
lineColor = '#0ECB81' // 绿色 - 止盈
|
||||
title = `TP ${order.quantity}`
|
||||
} else if (isLimit) {
|
||||
lineColor = '#F0B90B' // 黄色 - 限价单
|
||||
title = `Limit ${order.side} ${order.quantity}`
|
||||
} else {
|
||||
title = `${order.type} ${order.quantity}`
|
||||
}
|
||||
|
||||
const priceLine = candlestickSeriesRef.current?.createPriceLine({
|
||||
price: linePrice,
|
||||
color: lineColor,
|
||||
lineWidth: 1,
|
||||
lineStyle: lineStyle,
|
||||
axisLabelVisible: true,
|
||||
title: title,
|
||||
})
|
||||
|
||||
if (priceLine) {
|
||||
priceLinesRef.current.push(priceLine)
|
||||
}
|
||||
})
|
||||
console.log('[AdvancedChart] ✅ Created', priceLinesRef.current.length, 'price lines for pending orders')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AdvancedChart] Error loading open orders:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载 (延迟1秒等待图表初始化完成)
|
||||
const initialTimeout = setTimeout(loadOpenOrders, 1000)
|
||||
|
||||
// 60秒刷新一次挂单
|
||||
const openOrdersInterval = setInterval(loadOpenOrders, 60000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(initialTimeout)
|
||||
clearInterval(openOrdersInterval)
|
||||
}
|
||||
}, [symbol, traderID])
|
||||
|
||||
// 单独处理订单标记的显示/隐藏,避免重新加载数据
|
||||
useEffect(() => {
|
||||
if (!seriesMarkersRef.current) return
|
||||
@@ -767,12 +915,15 @@ export function AdvancedChart({
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Compact Professional Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#0D1117' }}
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#0D1117', flexShrink: 0 }}
|
||||
>
|
||||
{/* Left: Symbol Info + Price */}
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -929,8 +1080,8 @@ export function AdvancedChart({
|
||||
)}
|
||||
|
||||
{/* 图表容器 */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div ref={chartContainerRef} />
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
||||
<div ref={chartContainerRef} style={{ height: '100%', width: '100%' }} />
|
||||
|
||||
{/* OHLC Tooltip */}
|
||||
{tooltipData && (
|
||||
|
||||
@@ -1486,7 +1486,7 @@ export function BacktestPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
{tr('form.feeLabel')}
|
||||
|
||||
@@ -145,14 +145,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||
|
||||
return (
|
||||
<div className="nofx-glass rounded-lg border border-white/5 relative z-10 w-full h-[600px] flex flex-col">
|
||||
{/* Clean Professional Toolbar */}
|
||||
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
|
||||
}`}>
|
||||
{/*
|
||||
Premium Professional Toolbar
|
||||
Mobile: Single row, horizontal scroll with gradient mask
|
||||
Desktop: Standard flex-wrap/nowrap
|
||||
*/}
|
||||
<div
|
||||
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
|
||||
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
|
||||
>
|
||||
{/* Left: Tab Switcher */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('equity')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
|
||||
@@ -161,7 +166,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<span>{t('accountEquityCurve', language)}</span>
|
||||
<span className="hidden md:inline">{t('accountEquityCurve', language)}</span>
|
||||
<span className="md:hidden">Eq</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -172,33 +178,31 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
<span>{t('marketChart', language)}</span>
|
||||
<span className="hidden md:inline">{t('marketChart', language)}</span>
|
||||
<span className="md:hidden">Kline</span>
|
||||
</button>
|
||||
|
||||
{/* Market Type Pills - Only when kline active */}
|
||||
{/* Market Type Pills - Only when kline active, HIDDEN on mobile to save space */}
|
||||
{activeTab === 'kline' && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-white/10 mx-2" />
|
||||
<div className="flex items-center gap-1">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-70">{config.icon}</span>
|
||||
{language === 'zh' ? config.label.zh : config.label.en}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
<div className="hidden md:flex items-center gap-1 ml-2 border-l border-white/10 pl-2">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-70">{config.icon}</span>
|
||||
{language === 'zh' ? config.label.zh : config.label.en}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,8 +298,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden">
|
||||
{/* Tab Content - Chart autosizes to this container */}
|
||||
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden h-full min-h-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'equity' ? (
|
||||
<motion.div
|
||||
@@ -321,8 +325,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
symbol={chartSymbol}
|
||||
interval={interval}
|
||||
traderID={traderId}
|
||||
// Dynamic height to fill container
|
||||
height={550}
|
||||
// Dynamic auto-sizing via ResizeObserver
|
||||
exchange={currentExchange}
|
||||
onSymbolChange={setChartSymbol}
|
||||
/>
|
||||
|
||||
@@ -63,9 +63,15 @@ export function ChartWithOrders({
|
||||
return 0
|
||||
}
|
||||
|
||||
// 如果已经是数字(Unix 时间戳),直接返回
|
||||
// 如果已经是数字(Unix 时间戳)
|
||||
if (typeof time === 'number') {
|
||||
console.log('[ChartWithOrders] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
// 判断是毫秒还是秒:如果大于 10^12 则认为是毫秒(2001年之后的毫秒时间戳)
|
||||
if (time > 1000000000000) {
|
||||
const seconds = Math.floor(time / 1000)
|
||||
console.log('[ChartWithOrders] ✅ Unix timestamp (ms→s):', time, '→', seconds, '(', new Date(time).toISOString(), ')')
|
||||
return seconds
|
||||
}
|
||||
console.log('[ChartWithOrders] ✅ Unix timestamp (s):', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||
return time
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
@@ -13,6 +13,7 @@ type Page =
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'strategy-market'
|
||||
| 'data'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
@@ -98,6 +99,7 @@ export default function HeaderBar({
|
||||
{(() => {
|
||||
// Define all navigation tabs
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
@@ -306,209 +308,171 @@ export default function HeaderBar({
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={
|
||||
mobileMenuOpen
|
||||
? { height: 'auto', opacity: 1 }
|
||||
: { height: 0, opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="md:hidden overflow-hidden bg-nofx-bg-lighter border-t border-nofx-gold/10"
|
||||
>
|
||||
<div className="px-4 py-4 space-y-2">
|
||||
{/* Mobile Navigation Tabs - Show all tabs */}
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
]
|
||||
|
||||
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
|
||||
if (tab.requiresAuth && !isLoggedIn) {
|
||||
onLoginRequired?.(tab.label)
|
||||
setMobileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
if (onPageChange) {
|
||||
onPageChange(tab.page)
|
||||
}
|
||||
navigate(tab.path)
|
||||
setMobileMenuOpen(false)
|
||||
}
|
||||
|
||||
return navTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.page}
|
||||
onClick={() => handleMobileNavClick(tab)}
|
||||
className={`block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 w-full text-left px-4 py-3 rounded-lg
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-white hover:bg-white/5'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10"
|
||||
/>
|
||||
)}
|
||||
{tab.label}
|
||||
{tab.requiresAuth && !isLoggedIn && (
|
||||
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-nofx-gold/20 text-nofx-gold">
|
||||
{language === 'zh' ? '需登录' : 'Login'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
})()}
|
||||
|
||||
{/* Original Navigation Items - Only on home page */}
|
||||
{isHomePage &&
|
||||
[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
|
||||
className="block text-sm py-2 text-nofx-text-muted hover:text-white"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Social Links - Mobile */}
|
||||
<div className="py-3 flex items-center gap-3 border-t border-nofx-gold/20">
|
||||
<a
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-white"
|
||||
{/* Mobile Menu Overlay */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 md:hidden bg-black/90 backdrop-blur-xl"
|
||||
style={{ top: '64px' }} // Below header
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1, duration: 0.3 }}
|
||||
className="flex flex-col h-[calc(100vh-64px)] overflow-y-auto px-6 py-8"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#1DA1F2]"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#0088cc]"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-col gap-6 mb-12">
|
||||
{(() => {
|
||||
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
|
||||
{ page: 'data', path: '/data', label: language === 'zh' ? '数据' : 'Data', requiresAuth: false },
|
||||
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
|
||||
{ page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
|
||||
{ page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
|
||||
{ page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
|
||||
{ page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
|
||||
{ page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
|
||||
{ page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
|
||||
{ page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
|
||||
]
|
||||
|
||||
{/* Language Toggle */}
|
||||
<div className="py-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-nofx-text-muted">
|
||||
{t('language', language)}:
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'zh'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${language === 'en'
|
||||
? 'bg-yellow-500 text-black'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User info and logout for mobile when logged in */}
|
||||
{isLoggedIn && user && (
|
||||
<div
|
||||
className="mt-4 pt-4 border-t border-nofx-gold/20"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded bg-nofx-bg-lighter">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-nofx-text-muted">
|
||||
{t('loggedInAs', language)}
|
||||
</div>
|
||||
<div className="text-sm text-nofx-text-muted">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout()
|
||||
const handleMobileNavClick = (tab: typeof navTabs[0]) => {
|
||||
if (tab.requiresAuth && !isLoggedIn) {
|
||||
onLoginRequired?.(tab.label)
|
||||
setMobileMenuOpen(false)
|
||||
return
|
||||
}
|
||||
if (onPageChange) {
|
||||
onPageChange(tab.page)
|
||||
}
|
||||
navigate(tab.path)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center bg-nofx-danger/20 text-nofx-danger"
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
{/* Show login/register buttons when not logged in and not on login/register pages */}
|
||||
{!isLoggedIn &&
|
||||
currentPage !== 'login' &&
|
||||
currentPage !== 'register' && (
|
||||
<div className="space-y-2 mt-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors text-nofx-text-muted border border-nofx-text-muted hover:text-white hover:border-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
{registrationEnabled && (
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors bg-nofx-gold text-black hover:opacity-90"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{t('signUp', language)}
|
||||
</a>
|
||||
return navTabs.map((tab, i) => (
|
||||
<motion.button
|
||||
key={tab.page}
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.1 + i * 0.05 }}
|
||||
onClick={() => handleMobileNavClick(tab)}
|
||||
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
|
||||
${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
|
||||
>
|
||||
{currentPage === tab.page && (
|
||||
<motion.div
|
||||
layoutId="active-indicator"
|
||||
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
|
||||
/>
|
||||
)}
|
||||
{tab.label}
|
||||
{tab.requiresAuth && !isLoggedIn && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1">
|
||||
LOGIN_REQ
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
))
|
||||
})()}
|
||||
|
||||
{/* Original Page Links */}
|
||||
{isHomePage && (
|
||||
<div className="pt-6 border-t border-white/5 space-y-4">
|
||||
{[
|
||||
{ key: 'features', label: t('features', language) },
|
||||
{ key: 'howItWorks', label: t('howItWorks', language) },
|
||||
].map((item, i) => (
|
||||
<motion.a
|
||||
key={item.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 + i * 0.1 }}
|
||||
href={`#${item.key === 'features' ? 'features' : 'how-it-works'}`}
|
||||
className="block text-lg font-mono text-zinc-600 hover:text-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
{'>'} {item.label}
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="mt-auto space-y-8">
|
||||
{/* Social Links */}
|
||||
<div className="flex items-center gap-4">
|
||||
{[
|
||||
{ href: OFFICIAL_LINKS.github, icon: <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" /> },
|
||||
{ href: OFFICIAL_LINKS.twitter, icon: <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /> },
|
||||
{ href: OFFICIAL_LINKS.telegram, icon: <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> }
|
||||
].map((link, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-12 h-12 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 hover:text-nofx-gold hover:border-nofx-gold transition-colors"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
{link.icon}
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account / Lang */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Lang Switcher */}
|
||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||
{['zh', 'en'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
onLanguageChange?.(lang as Language)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${language === lang
|
||||
? 'bg-zinc-800 text-white shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? 'CN' : 'EN'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Auth Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout?.()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg font-bold text-sm hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
{t('exitLogin', language)}
|
||||
</button>
|
||||
) : (
|
||||
currentPage !== 'login' && currentPage !== 'register' && (
|
||||
<a
|
||||
href="/login"
|
||||
className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login, loginAdmin, verifyOTP } = useAuth()
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login')
|
||||
const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
|
||||
const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [qrCodeURL, setQrCodeURL] = useState('') // New state for recovery
|
||||
const [otpSecret, setOtpSecret] = useState('') // New state for recovery
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
@@ -62,9 +64,25 @@ export function LoginPage() {
|
||||
const result = await login(email, password)
|
||||
|
||||
if (result.success) {
|
||||
if (result.requiresOTP && result.userID) {
|
||||
// Check for incomplete OTP setup (user registered but didn't complete 2FA)
|
||||
if (result.requiresOTPSetup && result.userID) {
|
||||
setUserID(result.userID)
|
||||
setStep('otp')
|
||||
setQrCodeURL(result.qrCodeURL || '')
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||
} else if (result.requiresOTP && result.userID) {
|
||||
setUserID(result.userID)
|
||||
|
||||
// Check if backend provided recovery data (meaning 2FA is pending setup)
|
||||
if (result.qrCodeURL) {
|
||||
setQrCodeURL(result.qrCodeURL)
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.info("Pending 2FA setup detected. Please complete configuration.")
|
||||
} else {
|
||||
setStep('otp')
|
||||
}
|
||||
} else {
|
||||
// Dismiss the "login expired" toast on successful login (no OTP required)
|
||||
if (expiredToastId) {
|
||||
@@ -72,9 +90,18 @@ export function LoginPage() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
// Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
|
||||
if (result.qrCodeURL) {
|
||||
setUserID(result.userID || '') // We might need to ensure userID is returned in error case too, or derived
|
||||
setQrCodeURL(result.qrCodeURL)
|
||||
setOtpSecret(result.otpSecret || '')
|
||||
setStep('setup-otp')
|
||||
toast.warning(t('completeGapSetup', language) || "Incomplete setup detected. Please configure 2FA.")
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
@@ -85,7 +112,11 @@ export function LoginPage() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await verifyOTP(userID, otpCode)
|
||||
// If we have qrCodeURL, it means user needs to complete registration (first time OTP setup)
|
||||
// Otherwise, it's a normal login OTP verification
|
||||
const result = qrCodeURL
|
||||
? await completeRegistration(userID, otpCode)
|
||||
: await verifyOTP(userID, otpCode)
|
||||
|
||||
if (!result.success) {
|
||||
const msg = result.message || t('verificationFailed', language)
|
||||
@@ -96,12 +127,20 @@ export function LoginPage() {
|
||||
if (expiredToastId) {
|
||||
toast.dismiss(expiredToastId)
|
||||
}
|
||||
// Clear qrCodeURL after successful completion
|
||||
setQrCodeURL('')
|
||||
setOtpSecret('')
|
||||
}
|
||||
// 成功的话AuthContext会自动处理登录状态
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
|
||||
@@ -202,6 +241,66 @@ export function LoginPage() {
|
||||
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
|
||||
</button>
|
||||
</form>
|
||||
) : step === 'setup-otp' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center bg-zinc-900/50 p-4 rounded border border-zinc-800">
|
||||
<div className="text-xs font-mono text-zinc-400 mb-2">COMPLETE 2FA CONFIGURATION</div>
|
||||
{qrCodeURL ? (
|
||||
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
|
||||
alt="QR Code"
|
||||
className="w-32 h-32"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 bg-zinc-800 animate-pulse rounded inline-block"></div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<p className="text-[10px] text-zinc-500 uppercase tracking-widest mb-1">Backup Secret Key</p>
|
||||
<div className="flex items-center gap-2 justify-center bg-black/50 p-2 rounded border border-zinc-700/50 max-w-[200px] mx-auto">
|
||||
<code className="text-xs font-mono text-nofx-gold">{otpSecret}</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(otpSecret)}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
>
|
||||
<span className="text-[10px] uppercase border border-zinc-700 px-1 rounded">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">01</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Install Authenticator App</p>
|
||||
<p className="mb-2">Recommended: <span className="text-nofx-gold">Google Authenticator</span>.</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">02</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Scan & Verify</p>
|
||||
<p>Scan code above, then enter the 6-digit token below to activate your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep('otp')}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-colors font-mono shadow-lg"
|
||||
>
|
||||
I HAVE SCANNED THE CODE →
|
||||
</button>
|
||||
</div>
|
||||
) : step === 'login' ? (
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -303,6 +303,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
|
||||
{displayQty.toFixed(4)}
|
||||
</td>
|
||||
|
||||
{/* Position Value (Entry Price * Quantity) */}
|
||||
<td className="py-3 px-4 text-right font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatNumber(entryPrice * displayQty)}
|
||||
</td>
|
||||
|
||||
{/* P&L */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
||||
@@ -764,6 +769,12 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
>
|
||||
{t('positionHistory.qty', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{t('positionHistory.value', language)}
|
||||
</th>
|
||||
<th
|
||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
|
||||
@@ -348,7 +348,7 @@ export function RegisterPage() {
|
||||
{qrCodeURL ? (
|
||||
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(`otpauth://totp/NoFX:${email}?secret=${otpSecret}&issuer=NoFX`)}`}
|
||||
alt="QR Code"
|
||||
className="w-32 h-32"
|
||||
/>
|
||||
@@ -370,18 +370,42 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 font-mono text-xs text-zinc-400">
|
||||
<div className="flex gap-3">
|
||||
<span className="text-nofx-gold mt-0.5">01</span>
|
||||
<p>Install Google Authenticator or Authy on your mobile device.</p>
|
||||
<div className="space-y-4 font-mono text-xs text-zinc-400 bg-black/20 p-4 rounded border border-zinc-800/50">
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">01</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Install Authenticator App</p>
|
||||
<p className="mb-2">We highly recommend <span className="text-nofx-gold">Google Authenticator</span> for compatibility.</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">iOS</span>
|
||||
<span className="px-1.5 py-0.5 bg-zinc-800 rounded text-[10px] text-zinc-300 border border-zinc-700">Android</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-nofx-gold mt-0.5">02</span>
|
||||
<p>Scan the QR code above or manually enter the secret key.</p>
|
||||
|
||||
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">02</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Scan QR Code</p>
|
||||
<p>Open Google Authenticator, tap the <span className="text-white">+</span> button, and scan the code above.</p>
|
||||
<p className="text-[10px] text-zinc-500 mt-1 italic">Protocol: Time-Based OTP (TOTP)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-nofx-gold mt-0.5">03</span>
|
||||
<p>Proceed to verify the generated 6-digit token.</p>
|
||||
|
||||
<div className="w-full h-px bg-zinc-800/50"></div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<span className="text-nofx-gold font-bold mt-0.5">03</span>
|
||||
<div>
|
||||
<p className="font-bold text-white mb-1">Verify Token</p>
|
||||
<p>Enter the 6-digit code generated by the app.</p>
|
||||
<div className="mt-2 p-2 bg-yellow-500/10 border border-yellow-500/20 rounded text-[10px] text-yellow-500/80 flex gap-2 items-start">
|
||||
<span className="mt-px">⚠️</span>
|
||||
<span>Stuck? Ensure your phone's time is set to "Automatic". Time drift causes codes to fail.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
|
||||
import { useAuth } from '../../../contexts/AuthContext'
|
||||
|
||||
const agents = [
|
||||
{
|
||||
@@ -31,10 +32,10 @@ const agents = [
|
||||
{
|
||||
name: "GAMMA-RAY",
|
||||
class: "ARBITRAGE",
|
||||
desc: "Risk-free spatial price equalization.",
|
||||
desc: "Low-risk spatial price equalization.",
|
||||
apy: "24%",
|
||||
winRate: "99%",
|
||||
risk: "ZERO",
|
||||
risk: "LOW",
|
||||
color: "text-purple-400",
|
||||
border: "border-purple-400/30",
|
||||
bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]",
|
||||
@@ -43,7 +44,15 @@ const agents = [
|
||||
]
|
||||
|
||||
export default function AgentGrid() {
|
||||
// Simplified State to prevent crash
|
||||
const { user } = useAuth()
|
||||
|
||||
const handleInitialize = () => {
|
||||
if (user) {
|
||||
window.location.href = '/strategy-market'
|
||||
} else {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden">
|
||||
@@ -118,7 +127,10 @@ export default function AgentGrid() {
|
||||
</div>
|
||||
|
||||
{/* Action Btn */}
|
||||
<button className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white`}>
|
||||
<button
|
||||
onClick={handleInitialize}
|
||||
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
|
||||
>
|
||||
<span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +174,7 @@ export default function TerminalHero() {
|
||||
{/* Main Title - Massive & Impactful */}
|
||||
{/* Main Title - Massive & Impactful */}
|
||||
<div className="relative z-20 mix-blend-hard-light md:mix-blend-normal">
|
||||
<h1 className="text-6xl sm:text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.9] md:leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600 drop-shadow-2xl">
|
||||
<h1 className="text-5xl sm:text-6xl md:text-8xl lg:text-9xl font-black tracking-tighter leading-[0.9] md:leading-[0.8] mb-6 select-none bg-clip-text text-transparent bg-gradient-to-b from-white via-white to-zinc-600 drop-shadow-2xl">
|
||||
AGENTIC<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold via-white to-nofx-gold animate-shimmer bg-[length:200%_auto] tracking-tight filter drop-shadow-[0_0_15px_rgba(234,179,8,0.3)]">TRADING</span>
|
||||
</h1>
|
||||
@@ -344,14 +344,14 @@ function CommunityStats() {
|
||||
const stats = [
|
||||
{
|
||||
label: 'GITHUB STARS',
|
||||
value: isLoading ? '...' : (error ? '9.5k+' : stars.toLocaleString()),
|
||||
value: isLoading ? '...' : (error ? '9,700+' : stars.toLocaleString()),
|
||||
icon: Star,
|
||||
color: 'text-yellow-400',
|
||||
href: OFFICIAL_LINKS.github
|
||||
},
|
||||
{
|
||||
label: 'FORKS',
|
||||
value: isLoading ? '...' : (error ? '2.5k+' : forks.toLocaleString()),
|
||||
value: isLoading ? '...' : (error ? '2,600+' : forks.toLocaleString()),
|
||||
icon: GitFork,
|
||||
color: 'text-blue-400',
|
||||
href: `${OFFICIAL_LINKS.github}/fork`
|
||||
@@ -365,7 +365,7 @@ function CommunityStats() {
|
||||
},
|
||||
{
|
||||
label: 'DEV COMMUNITY',
|
||||
value: '5,800+', // Hardcoded as per user request
|
||||
value: '6,000+', // Updated as per user request
|
||||
icon: MessageCircle,
|
||||
color: 'text-blue-500',
|
||||
href: OFFICIAL_LINKS.telegram
|
||||
|
||||
424
web/src/components/strategy/GridConfigEditor.tsx
Normal file
424
web/src/components/strategy/GridConfigEditor.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import { Grid, DollarSign, TrendingUp, Shield } from 'lucide-react'
|
||||
import type { GridStrategyConfig } from '../../types'
|
||||
|
||||
interface GridConfigEditorProps {
|
||||
config: GridStrategyConfig
|
||||
onChange: (config: GridStrategyConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// Default grid config
|
||||
export const defaultGridConfig: GridStrategyConfig = {
|
||||
symbol: 'BTCUSDT',
|
||||
grid_count: 10,
|
||||
total_investment: 1000,
|
||||
leverage: 5,
|
||||
upper_price: 0,
|
||||
lower_price: 0,
|
||||
use_atr_bounds: true,
|
||||
atr_multiplier: 2.0,
|
||||
distribution: 'gaussian',
|
||||
max_drawdown_pct: 15,
|
||||
stop_loss_pct: 5,
|
||||
daily_loss_limit_pct: 10,
|
||||
use_maker_only: true,
|
||||
}
|
||||
|
||||
export function GridConfigEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: GridConfigEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
// Section titles
|
||||
tradingPair: { zh: '交易设置', en: 'Trading Setup' },
|
||||
gridParameters: { zh: '网格参数', en: 'Grid Parameters' },
|
||||
priceBounds: { zh: '价格边界', en: 'Price Bounds' },
|
||||
riskControl: { zh: '风险控制', en: 'Risk Control' },
|
||||
|
||||
// Trading pair
|
||||
symbol: { zh: '交易对', en: 'Trading Pair' },
|
||||
symbolDesc: { zh: '选择要进行网格交易的交易对', en: 'Select trading pair for grid trading' },
|
||||
|
||||
// Investment
|
||||
totalInvestment: { zh: '投资金额 (USDT)', en: 'Investment (USDT)' },
|
||||
totalInvestmentDesc: { zh: '网格策略的总投资金额', en: 'Total investment for grid strategy' },
|
||||
leverage: { zh: '杠杆倍数', en: 'Leverage' },
|
||||
leverageDesc: { zh: '交易使用的杠杆倍数 (1-20)', en: 'Leverage for trading (1-20)' },
|
||||
|
||||
// Grid parameters
|
||||
gridCount: { zh: '网格数量', en: 'Grid Count' },
|
||||
gridCountDesc: { zh: '网格层级数量 (5-50)', en: 'Number of grid levels (5-50)' },
|
||||
distribution: { zh: '资金分配方式', en: 'Distribution' },
|
||||
distributionDesc: { zh: '网格层级的资金分配方式', en: 'Fund allocation across grid levels' },
|
||||
uniform: { zh: '均匀分配', en: 'Uniform' },
|
||||
gaussian: { zh: '高斯分配 (推荐)', en: 'Gaussian (Recommended)' },
|
||||
pyramid: { zh: '金字塔分配', en: 'Pyramid' },
|
||||
|
||||
// Price bounds
|
||||
useAtrBounds: { zh: '自动计算边界 (ATR)', en: 'Auto-calculate Bounds (ATR)' },
|
||||
useAtrBoundsDesc: { zh: '基于 ATR 自动计算网格上下边界', en: 'Auto-calculate bounds based on ATR' },
|
||||
atrMultiplier: { zh: 'ATR 倍数', en: 'ATR Multiplier' },
|
||||
atrMultiplierDesc: { zh: '边界距离当前价格的 ATR 倍数', en: 'ATR multiplier for bounds distance' },
|
||||
upperPrice: { zh: '上边界价格', en: 'Upper Price' },
|
||||
upperPriceDesc: { zh: '网格上边界价格 (0=自动计算)', en: 'Grid upper bound (0=auto)' },
|
||||
lowerPrice: { zh: '下边界价格', en: 'Lower Price' },
|
||||
lowerPriceDesc: { zh: '网格下边界价格 (0=自动计算)', en: 'Grid lower bound (0=auto)' },
|
||||
|
||||
// Risk control
|
||||
maxDrawdown: { zh: '最大回撤 (%)', en: 'Max Drawdown (%)' },
|
||||
maxDrawdownDesc: { zh: '触发紧急退出的最大回撤百分比', en: 'Max drawdown before emergency exit' },
|
||||
stopLoss: { zh: '止损 (%)', en: 'Stop Loss (%)' },
|
||||
stopLossDesc: { zh: '单仓位止损百分比', en: 'Stop loss per position' },
|
||||
dailyLossLimit: { zh: '日损失限制 (%)', en: 'Daily Loss Limit (%)' },
|
||||
dailyLossLimitDesc: { zh: '每日最大亏损百分比', en: 'Maximum daily loss percentage' },
|
||||
useMakerOnly: { zh: '仅使用 Maker 订单', en: 'Maker Only Orders' },
|
||||
useMakerOnlyDesc: { zh: '使用限价单以降低手续费', en: 'Use limit orders for lower fees' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof GridStrategyConfig>(
|
||||
key: K,
|
||||
value: GridStrategyConfig[K]
|
||||
) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}
|
||||
|
||||
const sectionStyle = {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trading Setup */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<DollarSign className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('tradingPair')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Symbol */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('symbol')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('symbolDesc')}
|
||||
</p>
|
||||
<select
|
||||
value={config.symbol}
|
||||
onChange={(e) => updateField('symbol', e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="BTCUSDT">BTC/USDT</option>
|
||||
<option value="ETHUSDT">ETH/USDT</option>
|
||||
<option value="SOLUSDT">SOL/USDT</option>
|
||||
<option value="BNBUSDT">BNB/USDT</option>
|
||||
<option value="XRPUSDT">XRP/USDT</option>
|
||||
<option value="DOGEUSDT">DOGE/USDT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Investment */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('totalInvestment')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('totalInvestmentDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.total_investment}
|
||||
onChange={(e) => updateField('total_investment', parseFloat(e.target.value) || 1000)}
|
||||
disabled={disabled}
|
||||
min={100}
|
||||
step={100}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leverage */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('leverage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('leverageDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.leverage}
|
||||
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Parameters */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Grid className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('gridParameters')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Grid Count */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('gridCount')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('gridCountDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.grid_count}
|
||||
onChange={(e) => updateField('grid_count', parseInt(e.target.value) || 10)}
|
||||
disabled={disabled}
|
||||
min={5}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Distribution */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('distribution')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('distributionDesc')}
|
||||
</p>
|
||||
<select
|
||||
value={config.distribution}
|
||||
onChange={(e) => updateField('distribution', e.target.value as 'uniform' | 'gaussian' | 'pyramid')}
|
||||
disabled={disabled}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="uniform">{t('uniform')}</option>
|
||||
<option value="gaussian">{t('gaussian')}</option>
|
||||
<option value="pyramid">{t('pyramid')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Bounds */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('priceBounds')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* ATR Toggle */}
|
||||
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('useAtrBounds')}
|
||||
</label>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('useAtrBoundsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_atr_bounds}
|
||||
onChange={(e) => updateField('use_atr_bounds', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_atr_bounds ? (
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('atrMultiplier')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('atrMultiplierDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.atr_multiplier}
|
||||
onChange={(e) => updateField('atr_multiplier', parseFloat(e.target.value) || 2.0)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.5}
|
||||
className="w-32 px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('upperPrice')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('upperPriceDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.upper_price}
|
||||
onChange={(e) => updateField('upper_price', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('lowerPrice')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('lowerPriceDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.lower_price}
|
||||
onChange={(e) => updateField('lower_price', parseFloat(e.target.value) || 0)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risk Control */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('riskControl')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxDrawdown')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxDrawdownDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_drawdown_pct}
|
||||
onChange={(e) => updateField('max_drawdown_pct', parseFloat(e.target.value) || 15)}
|
||||
disabled={disabled}
|
||||
min={5}
|
||||
max={50}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('stopLoss')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('stopLossDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.stop_loss_pct}
|
||||
onChange={(e) => updateField('stop_loss_pct', parseFloat(e.target.value) || 5)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('dailyLossLimit')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('dailyLossLimitDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.daily_loss_limit_pct}
|
||||
onChange={(e) => updateField('daily_loss_limit_pct', parseFloat(e.target.value) || 10)}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maker Only Toggle */}
|
||||
<div className="p-4 rounded-lg" style={sectionStyle}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('useMakerOnly')}
|
||||
</label>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('useMakerOnlyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_maker_only}
|
||||
onChange={(e) => updateField('use_maker_only', e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
372
web/src/components/strategy/GridRiskPanel.tsx
Normal file
372
web/src/components/strategy/GridRiskPanel.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield, TrendingUp, AlertTriangle, Activity, Box, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import type { GridRiskInfo } from '../../types'
|
||||
|
||||
interface GridRiskPanelProps {
|
||||
traderId: string
|
||||
language?: string
|
||||
refreshInterval?: number // ms, default 5000
|
||||
}
|
||||
|
||||
export function GridRiskPanel({
|
||||
traderId,
|
||||
language = 'en',
|
||||
refreshInterval = 5000,
|
||||
}: GridRiskPanelProps) {
|
||||
const [riskInfo, setRiskInfo] = useState<GridRiskInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
// Section titles
|
||||
gridRisk: { zh: '网格风控', en: 'Grid Risk' },
|
||||
leverageInfo: { zh: '杠杆', en: 'Leverage' },
|
||||
positionInfo: { zh: '仓位', en: 'Position' },
|
||||
liquidationInfo: { zh: '清算', en: 'Liquidation' },
|
||||
marketState: { zh: '市场', en: 'Market' },
|
||||
boxState: { zh: '箱体', en: 'Box' },
|
||||
|
||||
// Leverage
|
||||
currentLeverage: { zh: '当前', en: 'Current' },
|
||||
effectiveLeverage: { zh: '有效', en: 'Effective' },
|
||||
recommendedLeverage: { zh: '建议', en: 'Recommend' },
|
||||
|
||||
// Position
|
||||
currentPosition: { zh: '当前', en: 'Current' },
|
||||
maxPosition: { zh: '最大', en: 'Max' },
|
||||
positionPercent: { zh: '占比', en: 'Usage' },
|
||||
|
||||
// Liquidation
|
||||
liquidationPrice: { zh: '清算价', en: 'Liq Price' },
|
||||
liquidationDistance: { zh: '距离', en: 'Distance' },
|
||||
|
||||
// Market
|
||||
regimeLevel: { zh: '波动', en: 'Regime' },
|
||||
currentPrice: { zh: '价格', en: 'Price' },
|
||||
breakoutLevel: { zh: '突破', en: 'Breakout' },
|
||||
breakoutDirection: { zh: '方向', en: 'Direction' },
|
||||
|
||||
// Box
|
||||
shortBox: { zh: '短期', en: 'Short' },
|
||||
midBox: { zh: '中期', en: 'Mid' },
|
||||
longBox: { zh: '长期', en: 'Long' },
|
||||
|
||||
// Regime levels
|
||||
narrow: { zh: '窄幅', en: 'Narrow' },
|
||||
standard: { zh: '标准', en: 'Standard' },
|
||||
wide: { zh: '宽幅', en: 'Wide' },
|
||||
volatile: { zh: '剧烈', en: 'Volatile' },
|
||||
trending: { zh: '趋势', en: 'Trending' },
|
||||
|
||||
// Breakout levels
|
||||
none: { zh: '无', en: 'None' },
|
||||
short: { zh: '短期', en: 'Short' },
|
||||
mid: { zh: '中期', en: 'Mid' },
|
||||
long: { zh: '长期', en: 'Long' },
|
||||
|
||||
// Directions
|
||||
up: { zh: '↑', en: '↑' },
|
||||
down: { zh: '↓', en: '↓' },
|
||||
|
||||
// Status
|
||||
loading: { zh: '加载中...', en: 'Loading...' },
|
||||
error: { zh: '加载失败', en: 'Load Failed' },
|
||||
noData: { zh: '暂无数据', en: 'No Data' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const fetchRiskInfo = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const response = await fetch(`/api/traders/${traderId}/grid-risk`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setRiskInfo(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [traderId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRiskInfo()
|
||||
const interval = setInterval(fetchRiskInfo, refreshInterval)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchRiskInfo, refreshInterval])
|
||||
|
||||
const getRegimeColor = (regime: string) => {
|
||||
switch (regime) {
|
||||
case 'narrow': return '#0ECB81'
|
||||
case 'standard': return '#F0B90B'
|
||||
case 'wide': return '#F7931A'
|
||||
case 'volatile': return '#F6465D'
|
||||
case 'trending': return '#8B5CF6'
|
||||
default: return '#848E9C'
|
||||
}
|
||||
}
|
||||
|
||||
const getBreakoutColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'none': return '#0ECB81'
|
||||
case 'short': return '#F0B90B'
|
||||
case 'mid': return '#F7931A'
|
||||
case 'long': return '#F6465D'
|
||||
default: return '#848E9C'
|
||||
}
|
||||
}
|
||||
|
||||
const getPositionColor = (percent: number) => {
|
||||
if (percent < 50) return '#0ECB81'
|
||||
if (percent < 80) return '#F0B90B'
|
||||
return '#F6465D'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
if (price === 0) return '-'
|
||||
if (price >= 1000) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
if (price >= 1) return price.toFixed(4)
|
||||
return price.toFixed(6)
|
||||
}
|
||||
|
||||
const formatUSD = (value: number) => {
|
||||
return `$${value.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#F6465D' }}>
|
||||
{t('error')}: {error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!riskInfo) {
|
||||
return (
|
||||
<div className="p-3 text-center text-xs" style={{ color: '#848E9C' }}>
|
||||
{t('noData')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg" style={cardStyle}>
|
||||
{/* Collapsible Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-[#1E2329] transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="font-medium text-sm" style={{ color: '#EAECEF' }}>
|
||||
{t('gridRisk')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Summary badges when collapsed */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded"
|
||||
style={{ background: getRegimeColor(riskInfo.regime_level) + '20', color: getRegimeColor(riskInfo.regime_level) }}
|
||||
>
|
||||
{t(riskInfo.regime_level || 'standard')}
|
||||
</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{riskInfo.effective_leverage.toFixed(1)}x
|
||||
</span>
|
||||
<span
|
||||
className="font-mono"
|
||||
style={{ color: getPositionColor(riskInfo.position_percent) }}
|
||||
>
|
||||
{riskInfo.position_percent.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronUp className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{/* Row 1: Leverage & Position */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Leverage */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<TrendingUp className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('leverageInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentLeverage')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{riskInfo.current_leverage}x</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('effectiveLeverage')}</div>
|
||||
<div className="font-mono" style={{ color: '#F0B90B' }}>{riskInfo.effective_leverage.toFixed(2)}x</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('recommendedLeverage')}</div>
|
||||
<div
|
||||
className="font-mono"
|
||||
style={{ color: riskInfo.current_leverage > riskInfo.recommended_leverage ? '#F6465D' : '#0ECB81' }}
|
||||
>
|
||||
{riskInfo.recommended_leverage}x
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Activity className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('positionInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentPosition')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatUSD(riskInfo.current_position)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('maxPosition')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatUSD(riskInfo.max_position)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('positionPercent')}</div>
|
||||
<div className="font-mono" style={{ color: getPositionColor(riskInfo.position_percent) }}>
|
||||
{riskInfo.position_percent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mini progress bar */}
|
||||
<div className="h-1 mt-2 rounded-full overflow-hidden" style={{ background: '#2B3139' }}>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.min(riskInfo.position_percent, 100)}%`, background: getPositionColor(riskInfo.position_percent) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Market State & Liquidation */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Market State */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Shield className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('marketState')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('regimeLevel')}</div>
|
||||
<div className="font-medium" style={{ color: getRegimeColor(riskInfo.regime_level) }}>
|
||||
{t(riskInfo.regime_level || 'standard')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('currentPrice')}</div>
|
||||
<div className="font-mono" style={{ color: '#EAECEF' }}>{formatPrice(riskInfo.current_price)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('breakoutLevel')}</div>
|
||||
<div className="font-medium" style={{ color: getBreakoutColor(riskInfo.breakout_level) }}>
|
||||
{t(riskInfo.breakout_level || 'none')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('breakoutDirection')}</div>
|
||||
<div
|
||||
className="font-medium"
|
||||
style={{ color: riskInfo.breakout_direction === 'up' ? '#0ECB81' : riskInfo.breakout_direction === 'down' ? '#F6465D' : '#848E9C' }}
|
||||
>
|
||||
{riskInfo.breakout_direction ? t(riskInfo.breakout_direction) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liquidation */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<AlertTriangle className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('liquidationInfo')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('liquidationPrice')}</div>
|
||||
<div className="font-mono" style={{ color: '#F6465D' }}>
|
||||
{riskInfo.liquidation_price > 0 ? formatPrice(riskInfo.liquidation_price) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: '#5E6673' }}>{t('liquidationDistance')}</div>
|
||||
<div className="font-mono" style={{ color: '#F6465D' }}>
|
||||
{riskInfo.liquidation_distance > 0 ? `${riskInfo.liquidation_distance.toFixed(1)}%` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Box State */}
|
||||
<div className="p-2 rounded" style={{ background: '#1E2329' }}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<Box className="w-3 h-3" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('boxState')}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('shortBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.short_box_lower)} - {formatPrice(riskInfo.short_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('midBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.mid_box_lower)} - {formatPrice(riskInfo.mid_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span style={{ color: '#5E6673' }}>{t('longBox')}</span>
|
||||
<span className="font-mono" style={{ color: '#EAECEF' }}>
|
||||
{formatPrice(riskInfo.long_box_lower)} - {formatPrice(riskInfo.long_box_upper)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,10 @@ interface AuthContextType {
|
||||
message?: string
|
||||
userID?: string
|
||||
requiresOTP?: boolean
|
||||
requiresOTPSetup?: boolean
|
||||
qrCodeURL?: string
|
||||
otpSecret?: string
|
||||
email?: string
|
||||
}>
|
||||
loginAdmin: (password: string) => Promise<{
|
||||
success: boolean
|
||||
@@ -119,22 +123,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Check for OTP setup required (incomplete registration)
|
||||
if (data.requires_otp_setup) {
|
||||
return {
|
||||
success: true,
|
||||
userID: data.user_id,
|
||||
requiresOTPSetup: true,
|
||||
message: data.message,
|
||||
qrCodeURL: data.qr_code_url,
|
||||
otpSecret: data.otp_secret,
|
||||
email: data.email
|
||||
}
|
||||
}
|
||||
// Check for OTP verification required (normal login flow)
|
||||
if (data.requires_otp) {
|
||||
return {
|
||||
success: true,
|
||||
userID: data.user_id,
|
||||
requiresOTP: true,
|
||||
message: data.message,
|
||||
qrCodeURL: data.qr_code_url,
|
||||
otpSecret: data.otp_secret
|
||||
}
|
||||
}
|
||||
// Unexpected success response
|
||||
return { success: false, message: '登录响应异常' }
|
||||
} else {
|
||||
return { success: false, message: data.error }
|
||||
return {
|
||||
success: false,
|
||||
message: data.error,
|
||||
qrCodeURL: data.qr_code_url,
|
||||
otpSecret: data.otp_secret,
|
||||
userID: data.user_id
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '登录失败,请重试' }
|
||||
}
|
||||
|
||||
return { success: false, message: '未知错误' }
|
||||
}
|
||||
|
||||
const loginAdmin = async (password: string) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user