mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-21 05:01:48 +08:00
Compare commits
27 Commits
release/st
...
fix/pr-tem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a579bc39d | ||
|
|
b10b9ec1a7 | ||
|
|
c1def0e2c2 | ||
|
|
705aa641b0 | ||
|
|
2f88205231 | ||
|
|
e92222950a | ||
|
|
138943d6fb | ||
|
|
b36ab27b65 | ||
|
|
5e65ae7077 | ||
|
|
c0c89d7534 | ||
|
|
3b2a3f4e76 | ||
|
|
c8458ec79c | ||
|
|
aee096ab1e | ||
|
|
165c0b1b5d | ||
|
|
4c097f7190 | ||
|
|
ea763a2471 | ||
|
|
6e6bdf1e57 | ||
|
|
f0b4913ad6 | ||
|
|
29cd79c626 | ||
|
|
7db37ade1c | ||
|
|
4804cfcb05 | ||
|
|
799d8b9c2e | ||
|
|
5c4c9cdc99 | ||
|
|
8b86d4d85c | ||
|
|
962df5c3ed | ||
|
|
9f3de6e3c0 | ||
|
|
5c9e134e99 |
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"]
|
||||
83
README.md
83
README.md
@@ -50,40 +50,12 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
## Before You Begin
|
||||
|
||||
### Config Page
|
||||
| AI Models & Exchanges | Traders List |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
|
||||
To use NOFX, you'll need:
|
||||
|
||||
### Competition & Backtest
|
||||
| Competition Mode | Backtest Lab |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/competition-page.png" width="400" alt="Competition Page"/> | <img src="screenshots/backtest-lab.png" width="400" alt="Backtest Lab"/> |
|
||||
|
||||
### Dashboard
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
|
||||
|
||||
| Trading Stats | Position History |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
|
||||
|
||||
### Strategy Studio
|
||||
| Strategy Editor | Indicators Config |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
|
||||
|
||||
### Debate Arena
|
||||
| AI Debate Session | Create Debate |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -122,9 +94,46 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Config Page
|
||||
| AI Models & Exchanges | Traders List |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400" alt="Config - AI Models & Exchanges"/> | <img src="screenshots/config-traders-list.png" width="400" alt="Config - Traders List"/> |
|
||||
|
||||
### Competition & Backtest
|
||||
| Competition Mode | Backtest Lab |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/competition-page.png" width="400" alt="Competition Page"/> | <img src="screenshots/backtest-lab.png" width="400" alt="Backtest Lab"/> |
|
||||
|
||||
### Dashboard
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-page.png" width="400" alt="Dashboard Overview"/> | <img src="screenshots/dashboard-market-chart.png" width="400" alt="Dashboard Market Chart"/> |
|
||||
|
||||
| Trading Stats | Position History |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400" alt="Trading Stats"/> | <img src="screenshots/dashboard-position-history.png" width="400" alt="Position History"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-positions.png" width="400" alt="Dashboard Positions"/> | <img src="screenshots/details-page.png" width="400" alt="Trader Details"/> |
|
||||
|
||||
### Strategy Studio
|
||||
| Strategy Editor | Indicators Config |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/strategy-studio.png" width="400" alt="Strategy Studio"/> | <img src="screenshots/strategy-indicators.png" width="400" alt="Strategy Indicators"/> |
|
||||
|
||||
### Debate Arena
|
||||
| AI Debate Session | Create Debate |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/debate-arena.png" width="400" alt="Debate Arena"/> | <img src="screenshots/debate-create.png" width="400" alt="Create Debate"/> |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### One-Click Install (Recommended)
|
||||
### One-Click Install (Local/Server)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -133,6 +142,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
|
||||
|
||||
130
api/server.go
130
api/server.go
@@ -202,6 +202,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)
|
||||
@@ -1452,9 +1453,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 +1483,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 +1558,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 {
|
||||
@@ -2294,28 +2295,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 +2342,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 +2989,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 +3040,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 +3161,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
|
||||
}
|
||||
|
||||
@@ -30,6 +30,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 デプロイ(推奨)
|
||||
|
||||
@@ -30,6 +30,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 배포 (권장)
|
||||
|
||||
@@ -30,6 +30,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 развёртывание (рекомендуется)
|
||||
|
||||
@@ -30,6 +30,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 розгортання (рекомендовано)
|
||||
|
||||
@@ -30,6 +30,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ị)
|
||||
|
||||
@@ -42,19 +42,12 @@
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
## 开始之前
|
||||
|
||||
### 竞赛模式 - 实时 AI 对战
|
||||

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

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

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

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

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

|
||||
*多数据源策略配置与 AI 测试*
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 一键安装 (推荐)
|
||||
### 一键安装 (本地/服务器)
|
||||
|
||||
**Linux / macOS:**
|
||||
```bash
|
||||
@@ -103,6 +113,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
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",
|
||||
|
||||
@@ -328,7 +328,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,16 @@ 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) {
|
||||
// TODO: Implement Aster open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
|
||||
@@ -637,7 +637,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 +744,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1967,6 +1967,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 +1977,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 +1999,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 +2052,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 +2094,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 +2218,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -776,6 +776,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())
|
||||
@@ -1122,7 +1180,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 +1225,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 +1268,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 +1302,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
216
trader/binance_sync_e2e_test.go
Normal file
216
trader/binance_sync_e2e_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
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,
|
||||
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
|
||||
lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||
} else {
|
||||
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, // 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
|
||||
lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
if lastFillTime.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,9 @@ 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) {
|
||||
// TODO: Implement Bitget open orders
|
||||
return []OpenOrder{}, 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,64 @@ 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,44 @@ 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
|
||||
}
|
||||
|
||||
@@ -94,4 +94,21 @@ 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -537,7 +537,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 +628,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)
|
||||
}
|
||||
|
||||
@@ -686,3 +686,9 @@ func pow10(n int) int64 {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Lighter open orders
|
||||
return []OpenOrder{}, nil
|
||||
}
|
||||
|
||||
@@ -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,9 @@ 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) {
|
||||
// TODO: Implement OKX open orders
|
||||
return []OpenOrder{}, 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'
|
||||
|
||||
@@ -20,13 +19,11 @@ 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,
|
||||
|
||||
@@ -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'
|
||||
@@ -306,209 +306,170 @@ 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: '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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1134,6 +1134,7 @@ export const translations = {
|
||||
entry: 'Entry',
|
||||
exit: 'Exit',
|
||||
qty: 'Qty',
|
||||
value: 'Value',
|
||||
lev: 'Lev',
|
||||
pnl: 'P&L',
|
||||
duration: 'Duration',
|
||||
@@ -2280,6 +2281,7 @@ export const translations = {
|
||||
entry: '开仓价',
|
||||
exit: '平仓价',
|
||||
qty: '数量',
|
||||
value: '仓位价值',
|
||||
lev: '杠杆',
|
||||
pnl: '盈亏',
|
||||
duration: '持仓时长',
|
||||
|
||||
@@ -471,7 +471,13 @@ textarea::placeholder {
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
font-size: 16px;
|
||||
|
||||
/* Prevent iOS zoom */
|
||||
@media (min-width: 768px) {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -788,8 +794,20 @@ tr:hover {
|
||||
color: var(--binance-red);
|
||||
}
|
||||
|
||||
.number-neutral {
|
||||
color: var(--text-secondary);
|
||||
/* Scrollbar Hiding for sleek horizontal scrolls */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Linear Fade Mask for Scrollable Areas */
|
||||
.mask-linear-fade {
|
||||
mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
|
||||
@@ -774,8 +774,19 @@ export function StrategyStudioPage() {
|
||||
disabled={selectedStrategy.is_default}
|
||||
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedStrategy.description || ''}
|
||||
onChange={(e) => {
|
||||
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
|
||||
setHasChanges(true)
|
||||
}}
|
||||
disabled={selectedStrategy.is_default}
|
||||
placeholder={language === 'zh' ? '添加策略简介...' : 'Add strategy description...'}
|
||||
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-nofx-gold">● 未保存</span>
|
||||
<span className="text-xs text-nofx-gold">● {language === 'zh' ? '未保存' : 'Unsaved'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { mutate } from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import { ChartTabs } from '../components/ChartTabs'
|
||||
@@ -362,7 +361,7 @@ export function TraderDashboardPage({
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-nofx-green rounded-full border-2 border-[#0B0E11] shadow-[0_0_8px_rgba(14,203,129,0.8)] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-nofx-text-main to-nofx-text-muted">
|
||||
<span className="text-3xl tracking-tight text-nofx-text font-semibold">
|
||||
{selectedTrader.trader_name}
|
||||
</span>
|
||||
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2">
|
||||
@@ -459,7 +458,7 @@ export function TraderDashboardPage({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span className="w-px h-3 bg-white/10 hidden md:block" />
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="opacity-60">Exchange:</span>
|
||||
<span className="text-nofx-text-main font-semibold">
|
||||
@@ -469,7 +468,7 @@ export function TraderDashboardPage({
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span className="w-px h-3 bg-white/10 hidden md:block" />
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="opacity-60">Strategy:</span>
|
||||
<span className="text-nofx-gold font-semibold tracking-wide">
|
||||
@@ -477,12 +476,12 @@ export function TraderDashboardPage({
|
||||
</span>
|
||||
</span>
|
||||
{status && (
|
||||
<>
|
||||
<div className="hidden md:contents">
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
|
||||
<span className="w-px h-3 bg-white/10" />
|
||||
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,7 +499,7 @@ export function TraderDashboardPage({
|
||||
)}
|
||||
|
||||
{/* Account Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
title={t('totalEquity', language)}
|
||||
value={`${account?.total_equity?.toFixed(2) || '0.00'}`}
|
||||
@@ -581,13 +580,13 @@ export function TraderDashboardPage({
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-left">{t('symbol', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{t('side', language)}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center">{language === 'zh' ? '操作' : 'Action'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('entryPrice', language)}>{language === 'zh' ? '入场价' : 'Entry'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('markPrice', language)}>{language === 'zh' ? '标记价' : 'Mark'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('entryPrice', language)}>{language === 'zh' ? '入场价' : 'Entry'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('markPrice', language)}>{language === 'zh' ? '标记价' : 'Mark'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('quantity', language)}>{language === 'zh' ? '数量' : 'Qty'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('positionValue', language)}>{language === 'zh' ? '价值' : 'Value'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('positionValue', language)}>{language === 'zh' ? '价值' : 'Value'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-center hidden md:table-cell" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('unrealizedPnL', language)}>{language === 'zh' ? '未实现盈亏' : 'uPnL'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right" title={t('liqPrice', language)}>{language === 'zh' ? '强平价' : 'Liq.'}</th>
|
||||
<th className="px-1 pb-3 font-semibold text-nofx-text-muted whitespace-nowrap text-right hidden md:table-cell" title={t('liqPrice', language)}>{language === 'zh' ? '强平价' : 'Liq.'}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -635,11 +634,11 @@ export function TraderDashboardPage({
|
||||
{language === 'zh' ? '平仓' : 'Close'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.entry_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.mark_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.entry_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.mark_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.quantity.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold">{pos.leverage}x</td>
|
||||
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold hidden md:table-cell">{pos.leverage}x</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
||||
<span
|
||||
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`}
|
||||
@@ -649,7 +648,7 @@ export function TraderDashboardPage({
|
||||
{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted">{pos.liquidation_price.toFixed(4)}</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{pos.liquidation_price.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -21,6 +21,7 @@ export default {
|
||||
'nofx-accent': '#00F0FF', // Cyan Cyber
|
||||
'nofx-text': {
|
||||
DEFAULT: '#EAECEF',
|
||||
main: '#EAECEF',
|
||||
muted: '#848E9C',
|
||||
},
|
||||
'nofx-success': '#0ECB81',
|
||||
|
||||
Reference in New Issue
Block a user