27 Commits

Author SHA1 Message Date
SkywalkerJi
3a579bc39d docs: update PR templates to English-only 2026-01-13 12:49:12 +08:00
SkywalkerJi
b10b9ec1a7 docs: convert PR templates to English-only (#1331) 2026-01-12 22:06:17 -06:00
tinkle-community
c1def0e2c2 fix: change GAMMA-RAY risk level from ZERO to LOW 2026-01-13 10:36:27 +08:00
tinkle-community
705aa641b0 fix: backtest module PostgreSQL compatibility and bug fixes
- Fix PostgreSQL placeholder conversion (? to $1, $2...) in all SQL queries
- Fix int4 overflow for timestamp columns (ALTER to BIGINT)
- Fix notional calculation bug in position Close() using proportional entry
- Fix potential panic in DecisionTimestamp with bounds check
- Fix nil pointer dereference in sliceUpTo with defensive checks
- Fix race condition in releaseLock using sync.Once
- Fix UnrealizedPnLPct always 0 in convertPositions
- Improve Sharpe ratio calculation with proper negative return handling
2026-01-09 01:48:02 +08:00
tinkle-community
2f88205231 fix: chart container height using flexbox layout 2026-01-08 15:48:33 +08:00
tinkle-community
e92222950a fix: use completeRegistration for incomplete OTP setup in login flow
- LoginPage: call completeRegistration instead of verifyOTP when qrCodeURL exists
- This ensures otp_verified is set to true for users completing OTP setup
- Backend: reorder maxUsers check to allow existing incomplete users to continue
- Backend: return OTP info when login with incomplete OTP setup
2026-01-07 20:15:27 +08:00
tinkle-community
138943d6fb fix: update xyz dex order routing configuration 2026-01-07 02:31:52 +08:00
tinkle-community
b36ab27b65 feat: add pending orders (SL/TP) display on chart
- Add GetOpenOrders method to Trader interface
- Implement for Binance (legacy + Algo), Bybit, Hyperliquid
- Add stub implementations for OKX, Bitget, Aster, Lighter
- Add /api/open-orders endpoint
- Display price lines for SL (red) and TP (green) orders
- Refresh open orders every 60 seconds (separate from 5s kline refresh)
2026-01-07 00:50:29 +08:00
tinkle-community
5e65ae7077 fix: chart order markers not displaying due to timestamp format mismatch
- Fix milliseconds to seconds conversion in parseCustomTime (AdvancedChart & ChartWithOrders)
- Add GetTraderOrdersFiltered to filter orders at database level by symbol/status
- Increase order limit from 50 to 200 for more historical orders
- Group multiple orders at same candle time and show count (B3, S5, etc.)
- Buy markers shown below bar (green), sell markers above bar (red)
2026-01-06 21:08:42 +08:00
tinkle-community
c0c89d7534 docs: update Railway deploy button with official template URL 2026-01-06 19:07:25 +08:00
tinkle-community
3b2a3f4e76 chore: clean up Railway deployment - remove debug code 2026-01-06 18:58:27 +08:00
tinkle-community
c8458ec79c fix: align PORT defaults to 8080 for Railway 2026-01-06 18:53:27 +08:00
tinkle-community
aee096ab1e debug: test nginx startup and internal health check 2026-01-06 18:48:11 +08:00
tinkle-community
165c0b1b5d debug: add nginx config test and file check 2026-01-06 18:44:24 +08:00
tinkle-community
4c097f7190 fix: use heredoc for nginx config to avoid envsubst issues 2026-01-06 18:41:08 +08:00
tinkle-community
ea763a2471 fix: use port 8081 for backend to avoid conflict with nginx 2026-01-06 18:37:18 +08:00
tinkle-community
6e6bdf1e57 refactor: simplify Railway deployment using existing GHCR images
- Use multi-stage build from existing backend/frontend images
- Remove supervisord, use simple shell script
- Single process model: backend runs in background, nginx foreground
- Auto-generate encryption keys on startup
2026-01-06 18:31:39 +08:00
tinkle-community
f0b4913ad6 debug: add PORT environment variable debugging 2026-01-06 18:19:28 +08:00
tinkle-community
29cd79c626 fix: use Railway PORT env var for nginx 2026-01-06 18:07:11 +08:00
tinkle-community
7db37ade1c fix: auto-generate encryption keys in Railway startup script 2026-01-06 17:59:29 +08:00
tinkle-community
4804cfcb05 feat: add Railway one-click deployment support
- Add Dockerfile.railway for all-in-one container
- Add railway.toml configuration
- Add railway/nginx.conf and supervisord.conf
- Update README with Deploy on Railway button
- Update Chinese README with deployment instructions
2026-01-06 17:32:09 +08:00
tinkle-community
799d8b9c2e feat: migrate timestamps to int64 and security improvements
- Convert all time.Time fields to int64 Unix milliseconds (UTC)
- Add PostgreSQL migration to convert timestamp columns to bigint
- Reduce Binance sync window from 7 days to 24 hours
- Fix dashboard trader name visibility (add nofx-text-main color)
- Add position value column to history table
- Remove hardcoded API keys from test files
2026-01-06 15:56:07 +08:00
tinkle-community
5c4c9cdc99 fix: handle large Binance trade IDs in Go to avoid database CAST limitations 2026-01-06 10:43:21 +08:00
tinkle-community
8b86d4d85c docs: add prerequisites section and reorganize README structure across all languages 2026-01-06 08:16:00 +08:00
tinkle-community
962df5c3ed feat: add strategy description input field 2026-01-05 00:08:51 +08:00
tinkle-community
9f3de6e3c0 fix: resolve hyperliquid order execution approval issue 2026-01-04 22:27:15 +08:00
tinkle-community
5c9e134e99 fix: ensure all timestamps use UTC timezone
- Add NowFunc to GORM config for UTC auto-generated timestamps
- Add .UTC() to all time.UnixMilli() calls in trader files
- Add .UTC() to all time.Now() calls in store and api files
- Fix TypeScript unused imports in frontend
2026-01-04 20:03:56 +08:00
77 changed files with 3664 additions and 1334 deletions

View File

@@ -1,16 +1,16 @@
# PR 标题指南 # PR Title Guide
## 📋 概述 ## 📋 Overview
我们使用 **Conventional Commits** 格式来保持 PR 标题的一致性,但这是**建议性的**,不会阻止你的 PR 被合并。 We use the **Conventional Commits** format to maintain consistency in PR titles, but this is **recommended**, not mandatory. It will not prevent your PR from being merged.
## ✅ 推荐格式 ## ✅ Recommended Format
``` ```
type(scope): description type(scope): description
``` ```
### 示例 ### Examples
``` ```
feat(trader): add new trading strategy feat(trader): add new trading strategy
@@ -22,63 +22,63 @@ ci(workflow): improve GitHub Actions
--- ---
## 📖 详细说明 ## 📖 Detailed Guide
### Type(类型)- 必需 ### Type - Required
描述这次变更的类型: Describes the type of change:
| Type | 说明 | 示例 | | Type | Description | Example |
|------|------|------| |------|-------------|---------|
| `feat` | 新功能 | `feat(trader): add stop-loss feature` | | `feat` | New feature | `feat(trader): add stop-loss feature` |
| `fix` | Bug 修复 | `fix(api): handle null response` | | `fix` | Bug fix | `fix(api): handle null response` |
| `docs` | 文档变更 | `docs: update installation guide` | | `docs` | Documentation change | `docs: update installation guide` |
| `style` | 代码格式(不影响代码运行) | `style: format code with prettier` | | `style` | Code formatting (no functional change) | `style: format code with prettier` |
| `refactor` | 重构(既不是新功能也不是修复) | `refactor(exchange): simplify connection logic` | | `refactor` | Code refactoring (neither feature nor fix) | `refactor(exchange): simplify connection logic` |
| `perf` | 性能优化 | `perf(ai): optimize prompt processing` | | `perf` | Performance optimization | `perf(ai): optimize prompt processing` |
| `test` | 添加或修改测试 | `test(trader): add unit tests` | | `test` | Add or modify tests | `test(trader): add unit tests` |
| `chore` | 构建过程或辅助工具的变动 | `chore: update dependencies` | | `chore` | Build process or auxiliary tool changes | `chore: update dependencies` |
| `ci` | CI/CD 相关变更 | `ci: add test coverage report` | | `ci` | CI/CD related changes | `ci: add test coverage report` |
| `security` | 安全相关修复 | `security: update vulnerable dependencies` | | `security` | Security fixes | `security: update vulnerable dependencies` |
| `build` | 构建系统或外部依赖项变更 | `build: upgrade webpack to v5` | | `build` | Build system or external dependency changes | `build: upgrade webpack to v5` |
### Scope(范围)- 可选 ### Scope - Optional
描述这次变更影响的范围: Describes the area affected by the change:
| Scope | 说明 | | Scope | Description |
|-------|------| |-------|-------------|
| `exchange` | 交易所相关 | | `exchange` | Exchange-related |
| `trader` | 交易员/交易策略 | | `trader` | Trader/trading strategy |
| `ai` | AI 模型相关 | | `ai` | AI model related |
| `api` | API 接口 | | `api` | API interface |
| `ui` | 用户界面 | | `ui` | User interface |
| `frontend` | 前端代码 | | `frontend` | Frontend code |
| `backend` | 后端代码 | | `backend` | Backend code |
| `security` | 安全相关 | | `security` | Security related |
| `deps` | 依赖项 | | `deps` | Dependencies |
| `workflow` | GitHub Actions workflows | | `workflow` | GitHub Actions workflows |
| `github` | GitHub 配置 | | `github` | GitHub configuration |
| `actions` | GitHub Actions | | `actions` | GitHub Actions |
| `config` | 配置文件 | | `config` | Configuration files |
| `docker` | Docker 相关 | | `docker` | Docker related |
| `build` | 构建相关 | | `build` | Build related |
| `release` | 发布相关 | | `release` | Release related |
**注意:** 如果变更影响多个范围,可以省略 scope 或选择最主要的。 **Note:** If the change affects multiple scopes, you can omit the scope or choose the most relevant one.
### Description(描述)- 必需 ### Description - Required
- 使用现在时态("add" 而不是 "added" - Use present tense ("add" not "added")
- 首字母小写 - Start with lowercase
- 结尾不加句号 - No period at the end
- 简洁明了地描述变更内容 - Concisely describe what changed
--- ---
## 🎯 完整示例 ## 🎯 Complete Examples
### ✅ 好的 PR 标题 ### ✅ Good PR Titles
``` ```
feat(trader): add risk management system feat(trader): add risk management system
@@ -94,38 +94,38 @@ security(api): fix SQL injection vulnerability
build(docker): optimize Docker image size build(docker): optimize Docker image size
``` ```
### ⚠️ 需要改进的标题 ### ⚠️ Titles That Need Improvement
| 不好的标题 | 问题 | 改进后 | | Poor Title | Issue | Improved |
|-----------|------|--------| |-----------|-------|----------|
| `update code` | 太模糊 | `refactor(trader): simplify order execution logic` | | `update code` | Too vague | `refactor(trader): simplify order execution logic` |
| `Fixed bug` | 首字母大写,不够具体 | `fix(api): handle edge case in login` | | `Fixed bug` | Capitalized, not specific | `fix(api): handle edge case in login` |
| `Add new feature.` | 有句号,不够具体 | `feat(ui): add dark mode toggle` | | `Add new feature.` | Has period, not specific | `feat(ui): add dark mode toggle` |
| `changes` | 完全不符合格式 | `chore: update dependencies` | | `changes` | Doesn't follow format | `chore: update dependencies` |
| `feat: Added new trading algo` | 时态错误 | `feat(trader): add new trading algorithm` | | `feat: Added new trading algo` | Wrong tense | `feat(trader): add new trading algorithm` |
--- ---
## 🤖 自动检查行为 ## 🤖 Automated Check Behavior
### 当 PR 标题不符合格式时 ### When PR Title Doesn't Follow Format
1. **不会阻止合并** 1. **Won't block merging**
- 检查会标记为"建议" - Check is marked as "advisory"
- PR 仍然可以被审查和合并 - PR can still be reviewed and merged
2. **会收到友好提示** 💬 2. **Provides friendly reminder** 💬
- 机器人会在 PR 中留言 - Bot will comment on the PR
- 提供格式说明和示例 - Provides format guidance and examples
- 建议如何改进标题 - Suggests how to improve the title
3. **可以随时更新** 🔄 3. **Can be updated anytime** 🔄
- 更新 PR 标题后会重新检查 - Re-checks after updating PR title
- 无需关闭和重新打开 PR - No need to close and reopen PR
### 示例评论 ### Example Comment
如果你的 PR 标题是 `update workflow`,你会收到这样的评论: If your PR title is `update workflow`, you'll receive a comment like this:
```markdown ```markdown
## ⚠️ PR Title Format Suggestion ## ⚠️ PR Title Format Suggestion
@@ -157,11 +157,11 @@ Your PR can still be reviewed and merged.
--- ---
## 🔧 配置详情 ## 🔧 Configuration Details
### 支持的 Types ### Supported Types
`.github/workflows/pr-checks.yml` 中配置: Configured in `.github/workflows/pr-checks.yml`:
```yaml ```yaml
types: | types: |
@@ -178,7 +178,7 @@ types: |
build build
``` ```
### 支持的 Scopes ### Supported Scopes
```yaml ```yaml
scopes: | scopes: |
@@ -200,38 +200,38 @@ scopes: |
release release
``` ```
### 添加新的 Scope ### Adding New Scopes
如果你需要添加新的 scope,请: If you need to add a new scope:
1. `.github/workflows/pr-checks.yml``scopes` 部分添加 1. Add it to the `scopes` section in `.github/workflows/pr-checks.yml`
2. `.github/workflows/pr-checks-run.yml` 更新正则表达式(可选) 2. Update the regex in `.github/workflows/pr-checks-run.yml` (optional)
3. 更新本文档 3. Update this documentation
--- ---
## 📚 为什么使用 Conventional Commits ## 📚 Why Use Conventional Commits?
### 优点 ### Benefits
1. **自动化 Changelog** 📝 1. **Automated Changelog** 📝
- 可以自动生成版本更新日志 - Automatically generate version changelogs
- 清晰地分类各种变更 - Clearly categorize different types of changes
2. **语义化版本** 🔢 2. **Semantic Versioning** 🔢
- `feat` → MINOR 版本(1.1.0 - `feat` → MINOR version (1.1.0)
- `fix` → PATCH 版本(1.0.1 - `fix` → PATCH version (1.0.1)
- `BREAKING CHANGE` → MAJOR 版本(2.0.0 - `BREAKING CHANGE` → MAJOR version (2.0.0)
3. **更好的可读性** 👀 3. **Better Readability** 👀
- 一眼看出 PR 的目的 - Understand PR purpose at a glance
- 更容易浏览 Git 历史 - Easier to browse Git history
4. **团队协作** 🤝 4. **Team Collaboration** 🤝
- 统一的提交风格 - Unified commit style
- 降低沟通成本 - Reduces communication overhead
### 示例:自动生成的 Changelog ### Example: Auto-generated Changelog
```markdown ```markdown
## v1.2.0 (2025-11-02) ## v1.2.0 (2025-11-02)
@@ -250,9 +250,9 @@ scopes: |
--- ---
## 🎓 学习资源 ## 🎓 Learning Resources
- **Conventional Commits 官网:** https://www.conventionalcommits.org/ - **Conventional Commits:** https://www.conventionalcommits.org/
- **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit - **Angular Commit Guidelines:** https://github.com/angular/angular/blob/main/CONTRIBUTING.md#commit
- **Semantic Versioning:** https://semver.org/ - **Semantic Versioning:** https://semver.org/
@@ -260,33 +260,33 @@ scopes: |
## ❓ FAQ ## ❓ FAQ
### Q: 我必须遵循这个格式吗? ### Q: Must I follow this format?
**A:** 不必须。这是建议性的,不会阻止你的 PR 被合并。但遵循格式可以提高项目的可维护性。 **A:** No. This is recommended but not mandatory. It won't block your PR from being merged. However, following the format improves project maintainability.
### Q: 如果我忘记了怎么办? ### Q: What if I forget?
**A:** 机器人会在 PR 中提醒你,你可以随时更新标题。 **A:** The bot will remind you in the PR comments. You can update the title anytime.
### Q: 我可以在一个 PR 中做多种类型的变更吗? ### Q: Can I make multiple types of changes in one PR?
**A:** 可以,但建议: **A:** Yes, but it's recommended to:
- 选择最主要的类型 - Choose the most significant type
- 或者考虑拆分成多个 PR更易于审查 - Or consider splitting into multiple PRs (easier to review)
### Q: Scope 可以省略吗? ### Q: Can I omit the scope?
**A:** 可以。`requireScope: false` 表示 scope 是可选的。 **A:** Yes. `requireScope: false` means scope is optional.
示例:`docs: update README` (没有 scope 也可以) Example: `docs: update README` (no scope is fine)
### Q: 我想添加新的 type scope,怎么做? ### Q: How do I add a new type or scope?
**A:** 提一个 PR 修改 `.github/workflows/pr-checks.yml`,并在本文档中说明新增项的用途。 **A:** Submit a PR to modify `.github/workflows/pr-checks.yml` and document the purpose of the new item in this guide.
### Q: Breaking Changes 怎么表示? ### Q: How do I indicate Breaking Changes?
**A:** 在描述中添加 `BREAKING CHANGE:` 或在 type 后加 `!` **A:** Add `BREAKING CHANGE:` in the description or add `!` after the type:
``` ```
feat!: remove deprecated API feat!: remove deprecated API
@@ -297,9 +297,9 @@ BREAKING CHANGE: The old /auth endpoint is removed
--- ---
## 📊 统计 ## 📊 Statistics
想看项目的 commit 类型分布?运行: Want to see the commit type distribution in your project? Run:
```bash ```bash
git log --oneline --no-merges | \ git log --oneline --no-merges | \
@@ -309,14 +309,14 @@ git log --oneline --no-merges | \
--- ---
## ✅ 快速检查清单 ## ✅ Quick Checklist
在提交 PR 前,检查你的标题是否: Before submitting a PR, check if your title:
- [ ] 包含有效的 typefeat, fix, docs 等) - [ ] Contains a valid type (feat, fix, docs, etc.)
- [ ] 使用小写字母开头 - [ ] Starts with lowercase
- [ ] 使用现在时态("add" 而不是 "added" - [ ] Uses present tense ("add" not "added")
- [ ] 简洁明了(最好在 50 字符内) - [ ] Is concise (preferably under 50 characters)
- [ ] 准确描述了变更内容 - [ ] Accurately describes the change
**记住:** 这些都是建议,不是强制要求! **Remember:** These are recommendations, not requirements!

View File

@@ -1,104 +1,100 @@
# Pull Request | PR 提交 # Pull Request
> **📋 选择专用模板 | Choose Specialized Template** > **📋 Choose Specialized Template**
> >
> 我们现在提供了针对不同类型PR的专用模板帮助你更快速地填写PR信息
> We now offer specialized templates for different types of PRs to help you fill out the information faster: > We now offer specialized templates for different types of PRs to help you fill out the information faster:
> >
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes > - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** - For Go/API/Trading changes
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes > - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** - For UI/UX changes
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates > - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** - For documentation updates
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes > - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** - For mixed or other changes
> >
> **如何使用?| How to use?** > **How to use?**
> - 创建PR时在URL中添加 `?template=backend.md` 或其他模板名称
> - When creating a PR, add `?template=backend.md` or other template name to the URL > - When creating a PR, add `?template=backend.md` or other template name to the URL
> - 或者直接复制粘贴对应模板的内容
> - Or simply copy and paste the content from the corresponding template > - Or simply copy and paste the content from the corresponding template
--- ---
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
<!-- Describe your changes in detail -->
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 📝 Documentation update | 文档更新 - [ ] 📝 Documentation update
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] ✅ Test update | 测试更新 - [ ] ✅ Test update
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:** <!-- List the specific changes made -->
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tests pass | 测试通过 - [ ] Tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:** <!-- Any additional information or context -->
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,213 +1,177 @@
# PR Templates | PR 模板 # PR Templates
## 📋 模板概述 | Template Overview ## 📋 Template Overview
我们提供了4种针对不同类型PR的专用模板帮助贡献者快速填写PR信息
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information: We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
### 1. 🔧 Backend Template | 后端模板 ### 1. 🔧 Backend Template
**文件:** `backend.md` **File:** `backend.md`
**适用于 | Use for:** **Use for:**
- Go代码变更 | Go code changes - Go code changes
- API端点开发 | API endpoint development - API endpoint development
- 交易逻辑实现 | Trading logic implementation - Trading logic implementation
- 后端性能优化 | Backend performance optimization - Backend performance optimization
- 数据库相关改动 | Database-related changes - Database-related changes
**包含 | Includes:** **Includes:**
- Go测试环境配置 | Go test environment - Go test environment
- 安全考虑检查 | Security considerations - Security considerations
- 性能影响评估 | Performance impact assessment - Performance impact assessment
- `go fmt``go build` 检查 | `go fmt` and `go build` checks - `go fmt` and `go build` checks
### 2. 🎨 Frontend Template | 前端模板 ### 2. 🎨 Frontend Template
**文件:** `frontend.md` **File:** `frontend.md`
**适用于 | Use for:** **Use for:**
- UI/UX变更 | UI/UX changes - UI/UX changes
- React/Vue组件开发 | React/Vue component development - React/Vue component development
- 前端样式更新 | Frontend styling updates - Frontend styling updates
- 浏览器兼容性修复 | Browser compatibility fixes - Browser compatibility fixes
- 前端性能优化 | Frontend performance optimization - Frontend performance optimization
**包含 | Includes:** **Includes:**
- 截图/演示要求 | Screenshots/demo requirements - Screenshots/demo requirements
- 浏览器测试清单 | Browser testing checklist - Browser testing checklist
- 国际化检查 | Internationalization checks - Internationalization checks
- 响应式设计验证 | Responsive design verification - Responsive design verification
- `npm run lint` `npm run build` 检查 | Linting and build checks - `npm run lint` and `npm run build` checks
### 3. 📝 Documentation Template | 文档模板 ### 3. 📝 Documentation Template
**文件:** `docs.md` **File:** `docs.md`
**适用于 | Use for:** **Use for:**
- README更新 | README updates - README updates
- API文档编写 | API documentation - API documentation
- 教程和指南 | Tutorials and guides - Tutorials and guides
- 代码注释改进 | Code comment improvements - Code comment improvements
- 翻译工作 | Translation work - Translation work
**包含 | Includes:** **Includes:**
- 文档类型分类 | Documentation type classification - Documentation type classification
- 内容质量检查 | Content quality checks - Content quality checks
- 双语要求(中英文)| Bilingual requirements (EN/CN) - Bilingual requirements (EN/CN)
- 链接有效性验证 | Link validity verification - Link validity verification
### 4. 📦 General Template | 通用模板 ### 4. 📦 General Template
**文件:** `general.md` **File:** `general.md`
**适用于 | Use for:** **Use for:**
- 混合类型变更 | Mixed-type changes - Mixed-type changes
- 跨多个领域的PR | Cross-domain PRs - Cross-domain PRs
- 构建配置变更 | Build configuration changes - Build configuration changes
- 依赖更新 | Dependency updates - Dependency updates
- 不确定使用哪个模板时 | When unsure which template to use - When unsure which template to use
## 🤖 自动模板建议 | Automatic Template Suggestion ## 🤖 Automatic Template Suggestion
我们的GitHub Action会自动分析你的PR并建议最合适的模板
Our GitHub Action automatically analyzes your PR and suggests the most suitable template: Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
### 工作原理 | How it works: ### How it works:
1. **文件分析 | File Analysis** 1. **File Analysis**
- 检测PR中所有变更的文件类型
- Detects all changed file types in the PR - Detects all changed file types in the PR
2. **智能判断 | Smart Detection** 2. **Smart Detection**
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
- If >50% are `.go` files → Suggests **Backend template** - If >50% are `.go` files → Suggests **Backend template**
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template** - If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
- If >70% are `.md` files → Suggests **Documentation template** - If >70% are `.md` files → Suggests **Documentation template**
3. **自动评论 | Auto-comment** 3. **Auto-comment**
- 如果检测到你使用了默认模板,但应该用专用模板
- If it detects you're using the default template but should use a specialized one - If it detects you're using the default template but should use a specialized one
- 会自动添加友好的评论建议
- It will automatically add a friendly comment suggestion - It will automatically add a friendly comment suggestion
4. **自动标签 | Auto-labeling** 4. **Auto-labeling**
- 自动添加对应的标签:`backend``frontend``documentation`
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation` - Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
## 📖 使用方法 | How to Use ## 📖 How to Use
### 方法1: URL参数推荐 | Method 1: URL Parameter (Recommended) ### Method 1: URL Parameter (Recommended)
创建PR时在URL末尾添加模板参数
When creating a PR, add the template parameter to the URL: When creating a PR, add the template parameter to the URL:
``` ```
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
``` ```
替换 `backend.md` 为:
Replace `backend.md` with: Replace `backend.md` with:
- `backend.md` - 后端模板 | Backend template - `backend.md` - Backend template
- `frontend.md` - 前端模板 | Frontend template - `frontend.md` - Frontend template
- `docs.md` - 文档模板 | Documentation template - `docs.md` - Documentation template
- `general.md` - 通用模板 | General template - `general.md` - General template
### 方法2: 手动选择 | Method 2: Manual Selection ### Method 2: Manual Selection
1. 创建PR时默认模板会显示 1. When creating a PR, the default template will be shown
When creating a PR, the default template will be shown
2. 根据顶部的指引链接,点击查看对应的模板 2. Follow the guidance links at the top to view the corresponding template
Follow the guidance links at the top to view the corresponding template
3. 复制模板内容到PR描述中 3. Copy the template content into the PR description
Copy the template content into the PR description
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion ### Method 3: Follow Auto-suggestion
1. 使用任何模板创建PR 1. Create a PR with any template
Create a PR with any template
2. GitHub Action会自动分析并评论建议 2. GitHub Action will automatically analyze and comment with a suggestion
GitHub Action will automatically analyze and comment with a suggestion
3. 根据建议更新PR描述 3. Update the PR description based on the suggestion
Update the PR description based on the suggestion
## 🎯 最佳实践 | Best Practices ## 🎯 Best Practices
1. **提前选择 | Choose in Advance** 1. **Choose in Advance**
- 在创建PR前确定变更类型
- Determine the change type before creating the PR - Determine the change type before creating the PR
2. **完整填写 | Complete Filling** 2. **Complete Filling**
- 不要跳过必填项(标记为 required
- Don't skip required items - Don't skip required items
3. **保持简洁 | Keep it Concise** 3. **Keep it Concise**
- 描述清晰但简洁
- Keep descriptions clear but concise - Keep descriptions clear but concise
4. **添加截图 | Add Screenshots** 4. **Add Screenshots**
- 对于UI变更务必添加截图
- For UI changes, always add screenshots - For UI changes, always add screenshots
5. **测试证明 | Test Evidence** 5. **Test Evidence**
- 提供测试通过的证据
- Provide evidence that tests pass - Provide evidence that tests pass
## 🔧 自定义 | Customization ## 🔧 Customization
如果需要修改模板或自动检测逻辑:
If you need to modify templates or auto-detection logic: If you need to modify templates or auto-detection logic:
1. **修改模板** | **Modify Templates** 1. **Modify Templates**
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files - Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
2. **调整检测阈值** | **Adjust Detection Threshold** 2. **Adjust Detection Threshold**
- 编辑 `.github/workflows/pr-template-suggester.yml`
- Edit `.github/workflows/pr-template-suggester.yml` - Edit `.github/workflows/pr-template-suggester.yml`
- 修改文件类型占比阈值当前50%后端50%前端70%文档)
- Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs) - Modify file type percentage thresholds (current: 50% backend, 50% frontend, 70% docs)
3. **添加新模板** | **Add New Template** 3. **Add New Template**
-`PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory - Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
- 更新工作流以支持新的文件类型检测
- Update the workflow to support new file type detection - Update the workflow to support new file type detection
## ❓ FAQ ## ❓ FAQ
**Q: 我的PR既有前端又有后端代码用哪个模板**
**Q: My PR has both frontend and backend code, which template should I use?** **Q: My PR has both frontend and backend code, which template should I use?**
A: 使用**通用模板**`general.md`),或选择主要变更类型的模板。
A: Use the **General template** (`general.md`), or choose the template for the primary change type. A: Use the **General template** (`general.md`), or choose the template for the primary change type.
--- ---
**Q: 自动建议的模板不合适怎么办?**
**Q: What if the automatically suggested template is not suitable?** **Q: What if the automatically suggested template is not suitable?**
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only. A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
--- ---
**Q: 可以不使用任何模板吗?**
**Q: Can I not use any template?** **Q: Can I not use any template?**
A: 不推荐。模板帮助确保PR包含必要信息加快审查速度。
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews. A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
--- ---
**Q: 如何禁用自动模板建议?**
**Q: How to disable automatic template suggestions?** **Q: How to disable automatic template suggestions?**
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file. A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
--- ---
🌟 **感谢使用我们的PR模板系统| Thank you for using our PR template system!** 🌟 **Thank you for using our PR template system!**

View File

@@ -1,121 +1,116 @@
# Pull Request - Backend | 后端 PR # Pull Request - Backend
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
### Test Environment | 测试环境 ### Test Environment
- **OS | 操作系统:** - **OS:**
- **Go Version | Go 版本:** - **Go Version:**
- **Exchange | 交易所:** [if applicable | 如适用] - **Exchange:** [if applicable]
### Manual Testing | 手动测试 ### Manual Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关) - [ ] Tested on testnet (for exchange integration)
- [ ] Unit tests pass | 单元测试通过 - [ ] Unit tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
### Test Results | 测试结果 ### Test Results
``` ```
Test output here | 测试输出 Test output here
``` ```
--- ---
## 🔒 Security Considerations | 安全考虑 ## 🔒 Security Considerations
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥 - [ ] No API keys or secrets hardcoded
- [ ] User inputs properly validated | 用户输入已正确验证 - [ ] User inputs properly validated
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞 - [ ] No SQL injection vulnerabilities
- [ ] Authentication/authorization properly handled | 认证/授权正确处理 - [ ] Authentication/authorization properly handled
- [ ] Sensitive data is encrypted | 敏感数据已加密 - [ ] Sensitive data is encrypted
- [ ] N/A (not security-related) | 不适用 - [ ] N/A (not security-related)
--- ---
## ⚡ Performance Impact | 性能影响 ## ⚡ Performance Impact
- [ ] No significant performance impact | 无显著性能影响 - [ ] No significant performance impact
- [ ] Performance improved | 性能提升 - [ ] Performance improved
- [ ] Performance may be impacted (explain below) | 性能可能受影响 - [ ] Performance may be impacted (explain below)
**If impacted, explain | 如果受影响,请说明:** **If impacted, explain:**
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] Code compiles successfully | 代码编译成功 (`go build`) - [ ] Code compiles successfully (`go build`)
- [ ] Ran `go fmt` | 已运行 `go fmt` - [ ] Ran `go fmt`
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Added inline comments where necessary | 已添加必要的代码注释 - [ ] Added inline comments where necessary
- [ ] Updated API documentation (if applicable) | 已更新 API 文档 - [ ] Updated API documentation (if applicable)
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,97 +1,91 @@
# Pull Request - Documentation | 文档 PR # Pull Request - Documentation
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description` > **💡 Tip:** Recommended PR title format `docs(scope): description`
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide` > Example: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 📚 Type of Documentation | 文档类型 ## 📚 Type of Documentation
- [ ] 📖 README update | README 更新 - [ ] 📖 README update
- [ ] 📋 API documentation | API 文档 - [ ] 📋 API documentation
- [ ] 🎓 Tutorial/Guide | 教程/指南 - [ ] 🎓 Tutorial/Guide
- [ ] 📝 Code comments | 代码注释 - [ ] 📝 Code comments
- [ ] 🔧 Configuration docs | 配置文档 - [ ] 🔧 Configuration docs
- [ ] 🐛 Fix typo/error | 修复拼写/错误 - [ ] 🐛 Fix typo/error
- [ ] 🌍 Translation | 翻译 - [ ] 🌍 Translation
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 📸 Screenshots (if applicable) | 截图(如适用) ## 📸 Screenshots (if applicable)
<!-- For documentation with images, diagrams, or UI examples --> <!-- For documentation with images, diagrams, or UI examples -->
<!-- 用于包含图片、图表或 UI 示例的文档 -->
--- ---
## 🌐 Internationalization | 国际化 ## 🌐 Internationalization
- [ ] English version complete | 英文版本完整 - [ ] English version complete
- [ ] Chinese version complete | 中文版本完整 - [ ] Chinese version complete
- [ ] Both versions are consistent | 两个版本内容一致 - [ ] Both versions are consistent
- [ ] N/A (only one language needed) | 不适用(只需要一种语言) - [ ] N/A (only one language needed)
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Content Quality | 内容质量 ### Content Quality
- [ ] Information is accurate and up-to-date | 信息准确且最新 - [ ] Information is accurate and up-to-date
- [ ] Language is clear and concise | 语言清晰简洁 - [ ] Language is clear and concise
- [ ] No spelling or grammar errors | 无拼写或语法错误 - [ ] No spelling or grammar errors
- [ ] Links are valid and working | 链接有效且可用 - [ ] Links are valid and working
- [ ] Code examples are tested and working | 代码示例已测试且可用 - [ ] Code examples are tested and working
- [ ] Formatting is consistent | 格式一致 - [ ] Formatting is consistent
### Documentation Standards | 文档标准 ### Documentation Standards
- [ ] Follows project documentation style | 遵循项目文档风格 - [ ] Follows project documentation style
- [ ] Includes necessary examples | 包含必要的示例 - [ ] Includes necessary examples
- [ ] Technical terms are explained | 技术术语已解释 - [ ] Technical terms are explained
- [ ] Self-review completed | 已完成自查 - [ ] Self-review completed
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,119 +1,113 @@
# Pull Request - Frontend | 前端 PR # Pull Request - Frontend
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug` > Example: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 📸 Screenshots / Demo | 截图/演示 ## 📸 Screenshots / Demo
<!-- For UI changes, include before/after screenshots or video demo --> <!-- For UI changes, include before/after screenshots or video demo -->
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
**Before | 变更前:** **Before:**
**After | 变更后:** **After:**
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
### Test Environment | 测试环境 ### Test Environment
- **OS | 操作系统:** - **OS:**
- **Node Version | Node 版本:** - **Node Version:**
- **Browser(s) | 浏览器:** - **Browser(s):**
### Manual Testing | 手动测试 ### Manual Testing
- [ ] Tested in development mode | 开发模式测试通过 - [ ] Tested in development mode
- [ ] Tested production build | 生产构建测试通过 - [ ] Tested production build
- [ ] Tested on multiple browsers | 多浏览器测试通过 - [ ] Tested on multiple browsers
- [ ] Tested responsive design | 响应式设计测试通过 - [ ] Tested responsive design
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
--- ---
## 🌐 Internationalization | 国际化 ## 🌐 Internationalization
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化 - [ ] All user-facing text supports i18n
- [ ] Both English and Chinese versions provided | 提供了中英文版本 - [ ] Both English and Chinese versions provided
- [ ] N/A | 不适用 - [ ] N/A
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] Code builds successfully | 代码构建成功 (`npm run build`) - [ ] Code builds successfully (`npm run build`)
- [ ] Ran `npm run lint` | 已运行 `npm run lint` - [ ] Ran `npm run lint`
- [ ] No console errors or warnings | 无控制台错误或警告 - [ ] No console errors or warnings
### Testing | 测试 ### Testing
- [ ] Component tests added/updated | 已添加/更新组件测试 - [ ] Component tests added/updated
- [ ] Tests pass locally | 测试在本地通过 - [ ] Tests pass locally
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Updated type definitions (TypeScript) | 已更新类型定义 - [ ] Updated type definitions (TypeScript)
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释 - [ ] Added JSDoc comments where necessary
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

View File

@@ -1,98 +1,93 @@
# Pull Request - General | 通用 PR # Pull Request - General
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description` > **💡 Tip:** Recommended PR title format `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update` > Example: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
--- ---
## 📝 Description | 描述 ## 📝 Description
**English:** **中文:**
--- ---
## 🎯 Type of Change | 变更类型 ## 🎯 Type of Change
- [ ] 🐛 Bug fix | 修复 Bug - [ ] 🐛 Bug fix
- [ ] ✨ New feature | 新功能 - [ ] ✨ New feature
- [ ] 💥 Breaking change | 破坏性变更 - [ ] 💥 Breaking change
- [ ] 📝 Documentation update | 文档更新 - [ ] 📝 Documentation update
- [ ] 🎨 Code style update | 代码样式更新 - [ ] 🎨 Code style update
- [ ] ♻️ Refactoring | 重构 - [ ] ♻️ Refactoring
- [ ] ⚡ Performance improvement | 性能优化 - [ ] ⚡ Performance improvement
- [ ] ✅ Test update | 测试更新 - [ ] ✅ Test update
- [ ] 🔧 Build/config change | 构建/配置变更 - [ ] 🔧 Build/config change
- [ ] 🔒 Security fix | 安全修复 - [ ] 🔒 Security fix
--- ---
## 🔗 Related Issues | 相关 Issue ## 🔗 Related Issues
- Closes # | 关闭 # - Closes #
- Related to # | 相关 # - Related to #
--- ---
## 📋 Changes Made | 具体变更 ## 📋 Changes Made
**English:** **中文:**
- -
- -
--- ---
## 🧪 Testing | 测试 ## 🧪 Testing
- [ ] Tested locally | 本地测试通过 - [ ] Tested locally
- [ ] Tests pass | 测试通过 - [ ] Tests pass
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能 - [ ] Verified no existing functionality broke
**Test details | 测试详情:** **Test details:**
--- ---
## ✅ Checklist | 检查清单 ## ✅ Checklist
### Code Quality | 代码质量 ### Code Quality
- [ ] Code follows project style | 代码遵循项目风格 - [ ] Code follows project style
- [ ] Self-review completed | 已完成代码自查 - [ ] Self-review completed
- [ ] Comments added for complex logic | 已添加必要注释 - [ ] Comments added for complex logic
- [ ] No new warnings or errors | 无新的警告或错误 - [ ] No new warnings or errors
### Documentation | 文档 ### Documentation
- [ ] Updated relevant documentation | 已更新相关文档 - [ ] Updated relevant documentation
- [ ] Added inline comments where necessary | 已添加必要的代码注释 - [ ] Added inline comments where necessary
### Git ### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式 - [ ] Commits follow conventional format
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支 - [ ] Rebased on latest `dev` branch
- [ ] No merge conflicts | 无合并冲突 - [ ] No merge conflicts
--- ---
## 🔒 Security (if applicable) | 安全(如适用) ## 🔒 Security (if applicable)
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥 - [ ] No API keys or secrets hardcoded
- [ ] User inputs properly validated | 用户输入已正确验证 - [ ] User inputs properly validated
- [ ] N/A | 不适用 - [ ] N/A
--- ---
## 📚 Additional Notes | 补充说明 ## 📚 Additional Notes
**English:** **中文:**
--- ---
**By submitting this PR, I confirm | 提交此 PR我确认** **By submitting this PR, I confirm:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南 - [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则 - [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证 - [ ] My contribution is licensed under AGPL-3.0
--- ---
🌟 **Thank you for your contribution! | 感谢你的贡献!** 🌟 **Thank you for your contribution!**

40
Dockerfile.railway Normal file
View 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"]

View File

@@ -50,40 +50,12 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
--- ---
## Screenshots ## Before You Begin
### Config Page To use NOFX, you'll need:
| 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 1. **Exchange Account** - Register on any supported exchange and create API credentials with trading permissions
| Competition Mode | Backtest Lab | 2. **AI Model API Key** - Get from any supported provider (DeepSeek recommended for cost-effectiveness)
|:---:|:---:|
| <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"/> |
--- ---
@@ -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 ## Quick Start
### One-Click Install (Recommended) ### One-Click Install (Local/Server)
**Linux / macOS:** **Linux / macOS:**
```bash ```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. 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:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
After deployment, Railway will provide a public URL to access your NOFX instance.
### Docker Compose (Manual) ### Docker Compose (Manual)
```bash ```bash

View File

@@ -202,6 +202,7 @@ func (s *Server) setupRoutes() {
protected.GET("/trades", s.handleTrades) protected.GET("/trades", s.handleTrades)
protected.GET("/orders", s.handleOrders) // Order list (all orders) protected.GET("/orders", s.handleOrders) // Order list (all orders)
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
protected.GET("/decisions", s.handleDecisions) protected.GET("/decisions", s.handleDecisions)
protected.GET("/decisions/latest", s.handleLatestDecisions) protected.GET("/decisions/latest", s.handleLatestDecisions)
protected.GET("/statistics", s.handleStatistics) protected.GET("/statistics", s.handleStatistics)
@@ -1452,9 +1453,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
FilledQuantity: quantity, FilledQuantity: quantity,
AvgFillPrice: exitPrice, AvgFillPrice: exitPrice,
Commission: fee, Commission: fee,
FilledAt: time.Now(), FilledAt: time.Now().UTC().UnixMilli(),
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now(), UpdatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateOrder(orderRecord); err != nil { if err := s.store.Order().CreateOrder(orderRecord); err != nil {
@@ -1482,7 +1483,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, RealizedPnL: 0,
IsMaker: false, IsMaker: false,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateFill(fillRecord); err != nil { if err := s.store.Order().CreateFill(fillRecord); err != nil {
@@ -1557,7 +1558,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, RealizedPnL: 0,
IsMaker: false, IsMaker: false,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
if err := s.store.Order().CreateFill(fillRecord); err != nil { if err := s.store.Order().CreateFill(fillRecord); err != nil {
@@ -2294,28 +2295,14 @@ func (s *Server) handleOrders(c *gin.Context) {
return return
} }
// Get all orders for this trader // Get orders with filters applied at database level
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit) orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
if err != nil { if err != nil {
SafeInternalError(c, "Get orders", err) SafeInternalError(c, "Get orders", err)
return return
} }
// Filter by symbol and status if specified c.JSON(http.StatusOK, orders)
result := make([]interface{}, 0)
for _, order := range allOrders {
// Filter by symbol
if symbol != "" && order.Symbol != symbol {
continue
}
// Filter by status
if statusFilter != "" && order.Status != statusFilter {
continue
}
result = append(result, order)
}
c.JSON(http.StatusOK, result)
} }
// handleOrderFills Order fill details (all fills for a specific order) // handleOrderFills Order fill details (all fills for a specific order)
@@ -2355,6 +2342,40 @@ func (s *Server) handleOrderFills(c *gin.Context) {
c.JSON(http.StatusOK, fills) c.JSON(http.StatusOK, fills)
} }
// handleOpenOrders Get open orders (pending SL/TP) from exchange
func (s *Server) handleOpenOrders(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
if err != nil {
SafeBadRequest(c, "Invalid trader ID")
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
SafeNotFound(c, "Trader")
return
}
// Get symbol parameter (required for exchange query)
symbol := c.Query("symbol")
if symbol == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
return
}
// Normalize symbol
symbol = market.Normalize(symbol)
// Get open orders from exchange
openOrders, err := trader.GetOpenOrders(symbol)
if err != nil {
SafeInternalError(c, "Get open orders", err)
return
}
c.JSON(http.StatusOK, openOrders)
}
// handleKlines K-line data (supports multiple exchanges via coinank) // handleKlines K-line data (supports multiple exchanges via coinank)
func (s *Server) handleKlines(c *gin.Context) { func (s *Server) handleKlines(c *gin.Context) {
// Get query parameters // Get query parameters
@@ -2968,7 +2989,44 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Check max users limit var req struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
SafeBadRequest(c, "Invalid request parameters")
return
}
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
existingUser, err := s.store.User().GetByEmail(req.Email)
if err == nil {
// User exists, check OTP verification status
if !existingUser.OTPVerified {
// OTP not verified, verify password first for security
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
return
}
// Password correct, allow user to continue OTP setup
// Return existing OTP information
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": existingUser.ID,
"email": existingUser.Email,
"otp_secret": existingUser.OTPSecret,
"qr_code_url": qrCodeURL,
"message": "Incomplete registration detected, please continue OTP setup",
})
return
}
// OTP already verified, reject duplicate registration
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
// Check max users limit (only for new users)
maxUsers := config.Get().MaxUsers maxUsers := config.Get().MaxUsers
if maxUsers > 0 { if maxUsers > 0 {
userCount, err := s.store.User().Count() userCount, err := s.store.User().Count()
@@ -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 // Generate password hash
passwordHash, err := auth.HashPassword(req.Password) passwordHash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
@@ -3120,10 +3161,15 @@ func (s *Server) handleLogin(c *gin.Context) {
// Check if OTP is verified // Check if OTP is verified
if !user.OTPVerified { if !user.OTPVerified {
c.JSON(http.StatusUnauthorized, gin.H{ // Return OTP info so user can complete setup
"error": "Account has not completed OTP setup", qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID, "user_id": user.ID,
"email": user.Email,
"otp_secret": user.OTPSecret,
"qr_code_url": qrCodeURL,
"requires_otp_setup": true, "requires_otp_setup": true,
"message": "Please complete OTP setup first",
}) })
return return
} }

View File

@@ -122,10 +122,10 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
} }
execPrice := applySlippage(price, acc.slippageRate, side, false) execPrice := applySlippage(price, acc.slippageRate, side, false)
notional := execPrice * quantity closeNotional := execPrice * quantity // Notional at close price (for fee calculation)
closingFee := notional * acc.feeRate closingFee := closeNotional * acc.feeRate
// Calculate proportional opening fee for the quantity being closed // Calculate proportional values based on the portion being closed
closePortion := quantity / pos.Quantity closePortion := quantity / pos.Quantity
openingFeePortion := pos.AccumulatedFee * closePortion openingFeePortion := pos.AccumulatedFee * closePortion
totalFee := closingFee + openingFeePortion totalFee := closingFee + openingFeePortion
@@ -133,13 +133,17 @@ func (acc *BacktestAccount) Close(symbol, side string, quantity float64, price f
realized := realizedPnL(pos, quantity, execPrice) realized := realizedPnL(pos, quantity, execPrice)
marginPortion := pos.Margin * closePortion marginPortion := pos.Margin * closePortion
// BUG FIX: Calculate notional portion based on ENTRY price, not close price
// pos.Notional tracks the total notional at entry, so we must subtract proportionally
entryNotionalPortion := pos.Notional * closePortion
// Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here // Note: Opening fee was already deducted from cash when opening, so we only deduct closing fee here
acc.cash += marginPortion + realized - closingFee acc.cash += marginPortion + realized - closingFee
// But for realized P&L tracking, we include both fees // But for realized P&L tracking, we include both fees
acc.realizedPnL += realized - totalFee acc.realizedPnL += realized - totalFee
pos.Quantity -= quantity pos.Quantity -= quantity
pos.Notional -= notional pos.Notional -= entryNotionalPortion // FIX: Use entry notional portion, not close notional
pos.Margin -= marginPortion pos.Margin -= marginPortion
pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee pos.AccumulatedFee -= openingFeePortion // Reduce tracked opening fee

View File

@@ -124,11 +124,23 @@ func (df *DataFeed) DecisionBarCount() int {
} }
func (df *DataFeed) DecisionTimestamp(index int) int64 { func (df *DataFeed) DecisionTimestamp(index int) int64 {
// Bounds check to prevent panic
if index < 0 || index >= len(df.decisionTimes) {
return 0
}
return df.decisionTimes[index] return df.decisionTimes[index]
} }
func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline { func (df *DataFeed) sliceUpTo(symbol, tf string, ts int64) []market.Kline {
series := df.symbolSeries[symbol].byTF[tf] // Nil checks to prevent panic
ss, ok := df.symbolSeries[symbol]
if !ok || ss == nil {
return nil
}
series, ok := ss.byTF[tf]
if !ok || series == nil {
return nil
}
idx := sort.Search(len(series.closeTimes), func(i int) bool { idx := sort.Search(len(series.closeTimes), func(i int) bool {
return series.closeTimes[i] > ts return series.closeTimes[i] > ts
}) })

View File

@@ -91,8 +91,13 @@ func maxDrawdown(points []EquityPoint, state *BacktestState) float64 {
return maxDD return maxDD
} }
// sharpeRatio calculates the Sharpe ratio from equity points.
// Uses sample standard deviation (n-1) and annualizes assuming ~252 trading days.
// Returns math.NaN() for edge cases (insufficient data, zero variance).
func sharpeRatio(points []EquityPoint) float64 { func sharpeRatio(points []EquityPoint) float64 {
if len(points) < 2 { // Need at least 10 data points for meaningful Sharpe calculation
const minDataPoints = 10
if len(points) < minDataPoints {
return 0 return 0
} }
@@ -108,34 +113,42 @@ func sharpeRatio(points []EquityPoint) float64 {
returns = append(returns, ret) returns = append(returns, ret)
prev = curr prev = curr
} }
if len(returns) == 0 { if len(returns) < minDataPoints-1 {
return 0 return 0
} }
// Calculate mean return
mean := 0.0 mean := 0.0
for _, r := range returns { for _, r := range returns {
mean += r mean += r
} }
mean /= float64(len(returns)) mean /= float64(len(returns))
// Calculate sample variance (using n-1 for unbiased estimator)
variance := 0.0 variance := 0.0
for _, r := range returns { for _, r := range returns {
diff := r - mean diff := r - mean
variance += diff * diff variance += diff * diff
} }
variance /= float64(len(returns)) if len(returns) > 1 {
variance /= float64(len(returns) - 1)
}
std := math.Sqrt(variance) std := math.Sqrt(variance)
if std == 0 { if std < 1e-10 {
if mean > 0 { // Zero or near-zero volatility - return 0 instead of infinity/NaN
return 999
}
if mean < 0 {
return -999
}
return 0 return 0
} }
return mean / std
// Calculate Sharpe ratio (assuming risk-free rate = 0 for crypto)
// Annualize by multiplying by sqrt(periods per year)
// Assuming each equity point represents ~1 hour, we have ~8760 periods/year
// For conservative estimate, use sqrt(252) as if daily returns
periodsPerYear := 252.0
annualizationFactor := math.Sqrt(periodsPerYear)
sharpe := (mean / std) * annualizationFactor
return sharpe
} }
func fillTradeMetrics(metrics *Metrics, events []TradeEvent) { func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
@@ -189,7 +202,8 @@ func fillTradeMetrics(metrics *Metrics, events []TradeEvent) {
if totalLossAmount > 0 { if totalLossAmount > 0 {
metrics.ProfitFactor = totalWinAmount / totalLossAmount metrics.ProfitFactor = totalWinAmount / totalLossAmount
} else if totalWinAmount > 0 { } else if totalWinAmount > 0 {
metrics.ProfitFactor = 999 // No losses but have wins - use a high but reasonable cap
metrics.ProfitFactor = 100.0
} }
bestSymbol := "" bestSymbol := ""

View File

@@ -2,15 +2,39 @@ package backtest
import ( import (
"database/sql" "database/sql"
"fmt"
"strings"
) )
var persistenceDB *sql.DB var persistenceDB *sql.DB
var dbIsPostgres bool
// UseDatabase enables database-backed persistence for all backtest storage operations. // UseDatabase enables database-backed persistence for all backtest storage operations.
// If isPostgres is true, queries will use $1, $2... placeholders instead of ?
func UseDatabase(db *sql.DB) { func UseDatabase(db *sql.DB) {
persistenceDB = db persistenceDB = db
} }
// UseDatabaseWithType enables database-backed persistence with explicit type.
func UseDatabaseWithType(db *sql.DB, isPostgres bool) {
persistenceDB = db
dbIsPostgres = isPostgres
}
func usingDB() bool { func usingDB() bool {
return persistenceDB != nil return persistenceDB != nil
} }
// convertQuery converts ? placeholders to $1, $2, etc. for PostgreSQL
func convertQuery(query string) string {
if !dbIsPostgres {
return query
}
result := query
index := 1
for strings.Contains(result, "?") {
result = strings.Replace(result, "?", fmt.Sprintf("$%d", index), 1)
index++
}
return result
}

View File

@@ -73,12 +73,12 @@ func enforceRetentionDB(maxRuns int) {
RunStateFailed, RunStateFailed,
RunStateLiquidated, RunStateLiquidated,
} }
query := ` query := convertQuery(`
SELECT run_id FROM backtest_runs SELECT run_id FROM backtest_runs
WHERE state IN (?, ?, ?, ?) WHERE state IN (?, ?, ?, ?)
ORDER BY updated_at DESC ORDER BY updated_at DESC
OFFSET ? OFFSET ?
` `)
rows, err := persistenceDB.Query(query, rows, err := persistenceDB.Query(query,
finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns) finalStates[0], finalStates[1], finalStates[2], finalStates[3], maxRuns)
if err != nil { if err != nil {

View File

@@ -60,8 +60,9 @@ type Runner struct {
aiCache *AICache aiCache *AICache
cachePath string cachePath string
lockInfo *RunLockInfo lockInfo *RunLockInfo
lockStop chan struct{} lockStop chan struct{}
lockStopOnce sync.Once // Ensures lockStop is closed only once
} }
// NewRunner constructs a backtest runner. // NewRunner constructs a backtest runner.
@@ -175,10 +176,12 @@ func (r *Runner) lockHeartbeatLoop() {
} }
func (r *Runner) releaseLock() { func (r *Runner) releaseLock() {
if r.lockStop != nil { // Use sync.Once to ensure channel is closed exactly once, preventing panic on double-close
close(r.lockStop) r.lockStopOnce.Do(func() {
r.lockStop = nil if r.lockStop != nil {
} close(r.lockStop)
}
})
if err := deleteRunLock(r.cfg.RunID); err != nil { if err := deleteRunLock(r.cfg.RunID); err != nil {
logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err) logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err)
} }
@@ -297,9 +300,12 @@ func (r *Runner) stepOnce() error {
if shouldDecide { if shouldDecide {
ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount) ctx, rec, err := r.buildDecisionContext(ts, marketData, multiTF, priceMap, callCount)
if err != nil { if err != nil {
rec.Success = false // Defensive nil check to prevent panic if buildDecisionContext returns error with nil record
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err) if rec != nil {
_ = r.logDecision(rec) rec.Success = false
rec.ErrorMessage = fmt.Sprintf("failed to build trading context: %v", err)
_ = r.logDecision(rec)
}
return err return err
} }
record = rec record = rec
@@ -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) { func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) {
symbol := dec.Symbol symbol := dec.Symbol
if symbol == "" {
return store.DecisionAction{}, nil, "", fmt.Errorf("empty symbol in decision")
}
usedLeverage := r.resolveLeverage(dec.Leverage, symbol) usedLeverage := r.resolveLeverage(dec.Leverage, symbol)
actionRecord := store.DecisionAction{ actionRecord := store.DecisionAction{
Action: dec.Action, Action: dec.Action,
@@ -625,9 +635,13 @@ func (r *Runner) executeDecision(dec kernel.Decision, priceMap map[string]float6
Timestamp: time.UnixMilli(ts).UTC(), Timestamp: time.UnixMilli(ts).UTC(),
} }
basePrice := priceMap[symbol] if priceMap == nil {
if basePrice <= 0 { return actionRecord, nil, "", fmt.Errorf("priceMap is nil")
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s", symbol) }
basePrice, ok := priceMap[symbol]
if !ok || basePrice <= 0 {
return actionRecord, nil, "", fmt.Errorf("price unavailable for %s (found=%v, price=%.4f)", symbol, ok, basePrice)
} }
fillPrice := r.executionPrice(symbol, basePrice, ts) fillPrice := r.executionPrice(symbol, basePrice, ts)
@@ -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 { func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
snapshot := r.snapshotState() snapshot := r.snapshotState()
equity := snapshot.Equity equity := snapshot.Equity
@@ -788,6 +805,13 @@ func (r *Runner) determineQuantity(dec kernel.Decision, price float64) float64 {
sizeUSD = maxPositionValue sizeUSD = maxPositionValue
} }
// Reject positions below minimum size to avoid dust positions
if sizeUSD < MinPositionSizeUSD {
logger.Infof("📊 Backtest: rejecting position size %.2f USD (below minimum %.2f USD)",
sizeUSD, MinPositionSizeUSD)
return 0
}
qty := sizeUSD / price qty := sizeUSD / price
if qty < 0 { if qty < 0 {
qty = 0 qty = 0
@@ -805,20 +829,37 @@ func (r *Runner) determineCloseQuantity(symbol, side string, dec kernel.Decision
} }
func (r *Runner) resolveLeverage(requested int, symbol string) int { func (r *Runner) resolveLeverage(requested int, symbol string) int {
if requested > 0 {
return requested
}
sym := strings.ToUpper(symbol) sym := strings.ToUpper(symbol)
if sym == "BTCUSDT" || sym == "ETHUSDT" { isBTCETH := sym == "BTCUSDT" || sym == "ETHUSDT"
if r.cfg.Leverage.BTCETHLeverage > 0 {
return r.cfg.Leverage.BTCETHLeverage // Determine configured max leverage for this symbol type
var maxLeverage int
if isBTCETH {
maxLeverage = r.cfg.Leverage.BTCETHLeverage
if maxLeverage <= 0 {
maxLeverage = 10 // Default max for BTC/ETH
} }
} else { } else {
if r.cfg.Leverage.AltcoinLeverage > 0 { maxLeverage = r.cfg.Leverage.AltcoinLeverage
return r.cfg.Leverage.AltcoinLeverage if maxLeverage <= 0 {
maxLeverage = 5 // Default max for altcoins
} }
} }
return 5
// Use requested leverage if provided, otherwise use max as default
leverage := requested
if leverage <= 0 {
leverage = maxLeverage
}
// Enforce max leverage limit
if leverage > maxLeverage {
logger.Infof("📊 Backtest: capping leverage from %dx to %dx for %s",
leverage, maxLeverage, symbol)
leverage = maxLeverage
}
return leverage
} }
func (r *Runner) remainingPosition(symbol, side string) float64 { func (r *Runner) remainingPosition(symbol, side string) float64 {
@@ -854,6 +895,12 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
list := make([]kernel.PositionInfo, 0, len(positions)) list := make([]kernel.PositionInfo, 0, len(positions))
for _, pos := range positions { for _, pos := range positions {
price := priceMap[pos.Symbol] price := priceMap[pos.Symbol]
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{ list = append(list, kernel.PositionInfo{
Symbol: pos.Symbol, Symbol: pos.Symbol,
Side: pos.Side, Side: pos.Side,
@@ -861,8 +908,8 @@ func (r *Runner) convertPositions(priceMap map[string]float64) []kernel.Position
MarkPrice: price, MarkPrice: price,
Quantity: pos.Quantity, Quantity: pos.Quantity,
Leverage: pos.Leverage, Leverage: pos.Leverage,
UnrealizedPnL: unrealizedPnL(pos, price), UnrealizedPnL: pnl,
UnrealizedPnLPct: 0, UnrealizedPnLPct: pnlPct,
LiquidationPrice: pos.LiquidationPrice, LiquidationPrice: pos.LiquidationPrice,
MarginUsed: pos.Margin, MarginUsed: pos.Margin,
UpdateTime: time.Now().UnixMilli(), UpdateTime: time.Now().UnixMilli(),

View File

@@ -17,17 +17,17 @@ func saveCheckpointDB(runID string, ckpt *Checkpoint) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_checkpoints (run_id, payload, updated_at) INSERT INTO backtest_checkpoints (run_id, payload, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
`, runID, data) `), runID, data)
return err return err
} }
func loadCheckpointDB(runID string) (*Checkpoint, error) { func loadCheckpointDB(runID string) (*Checkpoint, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, os.ErrNotExist return nil, os.ErrNotExist
@@ -57,25 +57,25 @@ func saveConfigDB(runID string, cfg *BacktestConfig) error {
if userID == "" { if userID == "" {
userID = "default" userID = "default"
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at) INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, override_prompt, ai_provider, ai_model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO NOTHING ON CONFLICT(run_id) DO NOTHING
`, runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now) `), runID, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, now, now)
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP
WHERE run_id = ? WHERE run_id = ?
`, userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID) `), userID, data, template, cfg.CustomPrompt, cfg.OverrideBasePrompt, cfg.AICfg.Provider, cfg.AICfg.Model, runID)
return err return err
} }
func loadConfigDB(runID string) (*BacktestConfig, error) { func loadConfigDB(runID string) (*BacktestConfig, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT config_json FROM backtest_runs WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -96,18 +96,18 @@ func saveRunMetadataDB(meta *RunMetadata) error {
if userID == "" { if userID == "" {
userID = "default" userID = "default"
} }
if _, err := persistenceDB.Exec(` if _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at) INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO NOTHING ON CONFLICT(run_id) DO NOTHING
`, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil { `), meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil {
return err return err
} }
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ? SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, liquidation_note = ?, label = ?, last_error = ?, updated_at = ?
WHERE run_id = ? WHERE run_id = ?
`, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID) `), userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, meta.Label, meta.LastError, updated, meta.RunID)
return err return err
} }
@@ -128,10 +128,10 @@ func loadRunMetadataDB(runID string) (*RunMetadata, error) {
createdISO string createdISO string
updatedISO string updatedISO string
) )
err := persistenceDB.QueryRow(` err := persistenceDB.QueryRow(convertQuery(`
SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, created_at, updated_at
FROM backtest_runs WHERE run_id = ? FROM backtest_runs WHERE run_id = ?
`, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO) `), runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, &createdISO, &updatedISO)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -183,18 +183,18 @@ func loadRunIDsDB() ([]string, error) {
} }
func appendEquityPointDB(runID string, point EquityPoint) error { func appendEquityPointDB(runID string, point EquityPoint) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle) INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle) `), runID, point.Timestamp, point.Equity, point.Available, point.PnL, point.PnLPct, point.DrawdownPct, point.Cycle)
return err return err
} }
func loadEquityPointsDB(runID string) ([]EquityPoint, error) { func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle
FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC
`, runID) `), runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -211,18 +211,18 @@ func loadEquityPointsDB(runID string) ([]EquityPoint, error) {
} }
func appendTradeEventDB(runID string, event TradeEvent) error { func appendTradeEventDB(runID string, event TradeEvent) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note) INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note) `), runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note)
return err return err
} }
func loadTradeEventsDB(runID string) ([]TradeEvent, error) { func loadTradeEventsDB(runID string) ([]TradeEvent, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, realized_pnl, leverage, cycle, position_after, liquidation, note
FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC
`, runID) `), runID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -243,17 +243,17 @@ func saveMetricsDB(runID string, metrics *Metrics) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_metrics (run_id, payload, updated_at) INSERT INTO backtest_metrics (run_id, payload, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP
`, runID, data) `), runID, data)
return err return err
} }
func loadMetricsDB(runID string) (*Metrics, error) { func loadMetricsDB(runID string) (*Metrics, error) {
var payload []byte var payload []byte
err := persistenceDB.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload) err := persistenceDB.QueryRow(convertQuery(`SELECT payload FROM backtest_metrics WHERE run_id = ?`), runID).Scan(&payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -265,22 +265,21 @@ func loadMetricsDB(runID string) (*Metrics, error) {
} }
func saveProgressDB(runID string, payload progressPayload) error { func saveProgressDB(runID string, payload progressPayload) error {
_, err := persistenceDB.Exec(` _, err := persistenceDB.Exec(convertQuery(`
UPDATE backtest_runs UPDATE backtest_runs
SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ? SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = ?
WHERE run_id = ? WHERE run_id = ?
`, payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID) `), payload.ProgressPct, payload.Equity, payload.BarIndex, payload.Liquidated, payload.UpdatedAtISO, runID)
return err return err
} }
func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) { func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) {
query := `SELECT payload FROM backtest_decisions WHERE run_id = ?`
var rows *sql.Rows var rows *sql.Rows
var err error var err error
if cycle > 0 { if cycle > 0 {
rows, err = persistenceDB.Query(query+` AND cycle = ? ORDER BY created_at DESC LIMIT 1`, runID, cycle) rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY created_at DESC LIMIT 1`), runID, cycle)
} else { } else {
rows, err = persistenceDB.Query(query+` ORDER BY created_at DESC LIMIT 1`, runID) rows, err = persistenceDB.Query(convertQuery(`SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY created_at DESC LIMIT 1`), runID)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -308,20 +307,20 @@ func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error {
if err != nil { if err != nil {
return err return err
} }
_, err = persistenceDB.Exec(` _, err = persistenceDB.Exec(convertQuery(`
INSERT INTO backtest_decisions (run_id, cycle, payload) INSERT INTO backtest_decisions (run_id, cycle, payload)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, runID, record.CycleNumber, data) `), runID, record.CycleNumber, data)
return err return err
} }
func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) { func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT payload FROM backtest_decisions SELECT payload FROM backtest_decisions
WHERE run_id = ? WHERE run_id = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`, runID, limit, offset) `), runID, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -428,10 +427,10 @@ func writeJSONLinesToZip[T any](z *zip.Writer, name string, items []T) error {
} }
func writeDecisionLogsToZip(z *zip.Writer, runID string) error { func writeDecisionLogsToZip(z *zip.Writer, runID string) error {
rows, err := persistenceDB.Query(` rows, err := persistenceDB.Query(convertQuery(`
SELECT id, cycle, payload FROM backtest_decisions SELECT id, cycle, payload FROM backtest_decisions
WHERE run_id = ? ORDER BY id ASC WHERE run_id = ? ORDER BY id ASC
`, runID) `), runID)
if err != nil { if err != nil {
return err return err
} }
@@ -494,6 +493,6 @@ func listIndexEntriesDB() ([]RunIndexEntry, error) {
} }
func deleteRunDB(runID string) error { func deleteRunDB(runID string) error {
_, err := persistenceDB.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID) _, err := persistenceDB.Exec(convertQuery(`DELETE FROM backtest_runs WHERE run_id = ?`), runID)
return err return err
} }

View File

@@ -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 デプロイ(推奨) ### オプション 1: Docker デプロイ(推奨)

View File

@@ -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 배포 (권장) ### 옵션 1: Docker 배포 (권장)

View File

@@ -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 развёртывание (рекомендуется) ### Вариант 1: Docker развёртывание (рекомендуется)

View File

@@ -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 розгортання (рекомендовано) ### Варіант 1: Docker розгортання (рекомендовано)

View File

@@ -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 ## Bắt Đầu Nhanh
### Tùy chọn 1: Triển khai Docker (Khuyến nghị) ### Tùy chọn 1: Triển khai Docker (Khuyến nghị)

View File

@@ -42,19 +42,12 @@
--- ---
## 截图 ## 开始之前
### 竞赛模式 - 实时 AI 对战 使用 NOFX 你需要准备:
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
### 仪表板 - 市场图表视图 1. **交易所账户** - 在任意支持的交易所注册并创建具有交易权限的 API 凭证
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png) 2. **AI 模型 API Key** - 从任意支持的提供商获取(推荐 DeepSeek性价比最高
*专业交易仪表板TradingView 风格图表*
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
--- ---
@@ -67,6 +60,7 @@
| **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) | | **Binance** | ✅ 已支持 | [注册](https://www.binance.com/join?ref=NOFXENG) |
| **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) | | **Bybit** | ✅ 已支持 | [注册](https://partner.bybit.com/b/83856) |
| **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) | | **OKX** | ✅ 已支持 | [注册](https://www.okx.com/join/1865360) |
| **Bitget** | ✅ 已支持 | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
### Perp-DEX (去中心化永续交易所) ### Perp-DEX (去中心化永续交易所)
@@ -92,9 +86,25 @@
--- ---
## 截图
### 竞赛模式 - 实时 AI 对战
![竞赛页面](../../../screenshots/competition-page.png)
*多 AI 排行榜,实时性能对比*
### 仪表板 - 市场图表视图
![仪表板市场图表](../../../screenshots/dashboard-market-chart.png)
*专业交易仪表板TradingView 风格图表*
### 策略工作室
![策略工作室](../../../screenshots/strategy-studio.png)
*多数据源策略配置与 AI 测试*
---
## 快速开始 ## 快速开始
### 一键安装 (推荐) ### 一键安装 (本地/服务器)
**Linux / macOS:** **Linux / macOS:**
```bash ```bash
@@ -103,6 +113,14 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
完成!打开浏览器访问 **http://127.0.0.1:3000** 完成!打开浏览器访问 **http://127.0.0.1:3000**
### 一键云部署 (Railway)
一键部署到 Railway - 无需自己搭建服务器:
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/deploy/nofx?referralCode=nofx)
部署后Railway 会提供一个公网 URL 访问你的 NOFX 实例。
### Docker Compose (手动) ### Docker Compose (手动)
```bash ```bash

View File

@@ -1,7 +1,5 @@
package kernel package kernel
import "fmt"
// ============================================================================ // ============================================================================
// Trading Data Schema - 交易数据字典 // Trading Data Schema - 交易数据字典
// ============================================================================ // ============================================================================
@@ -481,18 +479,6 @@ func getSchemaPromptZH() string {
prompt += formatFieldDefZH(key, field) prompt += formatFieldDefZH(key, field)
} }
// 交易规则
prompt += "\n## ⚖️ 交易规则\n\n"
prompt += "### 风险管理\n"
for name, rule := range TradingRules.RiskManagement {
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
}
prompt += "\n### 出场信号\n"
for name, rule := range TradingRules.ExitSignals {
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
}
// OI解读 // OI解读
prompt += "\n## 💹 持仓量(OI)变化解读\n\n" prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n" prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
@@ -500,14 +486,6 @@ func getSchemaPromptZH() string {
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n" prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n" prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
// 常见错误
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
for i, mistake := range CommonMistakes {
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
}
return prompt return prompt
} }
@@ -540,18 +518,6 @@ func getSchemaPromptEN() string {
prompt += formatFieldDefEN(key, field) prompt += formatFieldDefEN(key, field)
} }
// Trading Rules
prompt += "\n## ⚖️ Trading Rules\n\n"
prompt += "### Risk Management\n"
for name, rule := range TradingRules.RiskManagement {
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
}
prompt += "\n### Exit Signals\n"
for name, rule := range TradingRules.ExitSignals {
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
}
// OI Interpretation // OI Interpretation
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n" prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n" prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
@@ -559,14 +525,6 @@ func getSchemaPromptEN() string {
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n" prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n" prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
// Common Mistakes
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
for i, mistake := range CommonMistakes {
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
}
return prompt return prompt
} }

View File

@@ -147,10 +147,7 @@ func TestGetSchemaPrompt(t *testing.T) {
"交易指标", "交易指标",
"持仓指标", "持仓指标",
"市场数据", "市场数据",
"交易规则",
"风险管理",
"持仓量(OI)变化解读", "持仓量(OI)变化解读",
"常见错误",
} }
for _, keyword := range mustContain { for _, keyword := range mustContain {
@@ -174,10 +171,7 @@ func TestGetSchemaPrompt(t *testing.T) {
"Trade Metrics", "Trade Metrics",
"Position Metrics", "Position Metrics",
"Market Data", "Market Data",
"Trading Rules",
"Risk Management",
"Open Interest", "Open Interest",
"Common Mistakes",
} }
for _, keyword := range mustContain { for _, keyword := range mustContain {

View File

@@ -78,7 +78,7 @@ func main() {
logger.Fatalf("❌ Failed to initialize database: %v", err) logger.Fatalf("❌ Failed to initialize database: %v", err)
} }
defer st.Close() defer st.Close()
backtest.UseDatabase(st.DB()) backtest.UseDatabaseWithType(st.DB(), st.DBType() == store.DBTypePostgres)
// Initialize installation ID for experience improvement (anonymous statistics) // Initialize installation ID for experience improvement (anonymous statistics)
initInstallationID(st) initInstallationID(st)

8
railway.toml Normal file
View 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
View 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

View File

@@ -7,6 +7,7 @@ import (
"nofx/store" "nofx/store"
"os" "os"
"path/filepath" "path/filepath"
"time"
) )
func main() { func main() {
@@ -83,7 +84,7 @@ func main() {
filledOrders++ filledOrders++
// 检查 filled_at // 检查 filled_at
if !order.FilledAt.IsZero() { if order.FilledAt > 0 {
withFilledAt++ withFilledAt++
} else { } else {
missingFilledAt++ missingFilledAt++
@@ -119,8 +120,8 @@ func main() {
} }
filledAtStr := "N/A" filledAtStr := "N/A"
if !order.FilledAt.IsZero() { if order.FilledAt > 0 {
filledAtStr = order.FilledAt.Format("01-02 15:04") filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
} }
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n", fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",

View File

@@ -149,7 +149,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"enabled": enabled, "enabled": enabled,
"custom_api_url": customAPIURL, "custom_api_url": customAPIURL,
"custom_model_name": customModelName, "custom_model_name": customModelName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
} }
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString) // If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
if apiKey != "" { if apiKey != "" {
@@ -167,7 +167,7 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
"enabled": enabled, "enabled": enabled,
"custom_api_url": customAPIURL, "custom_api_url": customAPIURL,
"custom_model_name": customModelName, "custom_model_name": customModelName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
} }
if apiKey != "" { if apiKey != "" {
updates["api_key"] = crypto.EncryptedString(apiKey) updates["api_key"] = crypto.EncryptedString(apiKey)

View File

@@ -147,7 +147,7 @@ func (BacktestCheckpoint) TableName() string {
type BacktestEquity struct { type BacktestEquity struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"` RunID string `gorm:"column:run_id;not null;index:idx_backtest_equity_run_ts"`
TS int64 `gorm:"column:ts;not null;index:idx_backtest_equity_run_ts"` TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_equity_run_ts"`
Equity float64 `gorm:"column:equity;not null"` Equity float64 `gorm:"column:equity;not null"`
Available float64 `gorm:"column:available;not null"` Available float64 `gorm:"column:available;not null"`
PnL float64 `gorm:"column:pnl;not null"` PnL float64 `gorm:"column:pnl;not null"`
@@ -164,7 +164,7 @@ func (BacktestEquity) TableName() string {
type BacktestTrade struct { type BacktestTrade struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"` RunID string `gorm:"column:run_id;not null;index:idx_backtest_trades_run_ts"`
TS int64 `gorm:"column:ts;not null;index:idx_backtest_trades_run_ts"` TS int64 `gorm:"column:ts;type:bigint;not null;index:idx_backtest_trades_run_ts"`
Symbol string `gorm:"column:symbol;not null"` Symbol string `gorm:"column:symbol;not null"`
Action string `gorm:"column:action;not null"` Action string `gorm:"column:action;not null"`
Side string `gorm:"column:side;default:''"` Side string `gorm:"column:side;default:''"`
@@ -217,7 +217,10 @@ func (s *BacktestStore) initTables() error {
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists) s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'backtest_runs'`).Scan(&tableExists)
if tableExists > 0 { if tableExists > 0 {
// Tables exist - just ensure indexes exist // Tables exist - fix column types and ensure indexes exist
// Fix ts column type from INTEGER to BIGINT (timestamps in milliseconds exceed int4 max)
s.db.Exec(`ALTER TABLE backtest_equity ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`ALTER TABLE backtest_trades ALTER COLUMN ts TYPE BIGINT`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`) s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`)

View File

@@ -236,7 +236,7 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
"aster_signer": asterSigner, "aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr, "lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex, "lighter_api_key_index": lighterApiKeyIndex,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
} }
// Only update encrypted fields if not empty // Only update encrypted fields if not empty
@@ -275,7 +275,7 @@ func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error
Where("id = ? AND user_id = ?", id, userID). Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"account_name": accountName, "account_name": accountName,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}) })
if result.Error != nil { if result.Error != nil {
return result.Error return result.Error

View File

@@ -2,6 +2,7 @@ package store
import ( import (
"fmt" "fmt"
"time"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -21,6 +22,10 @@ func DB() *gorm.DB {
func InitGorm(dbPath string) (*gorm.DB, error) { func InitGorm(dbPath string) (*gorm.DB, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
NowFunc: func() time.Time {
return time.Now().UTC()
},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open SQLite database: %w", err) return nil, fmt.Errorf("failed to open SQLite database: %w", err)
@@ -53,6 +58,10 @@ func InitGormPostgres(host string, port int, user, password, dbname, sslmode str
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Silent),
// Use UTC for all auto-generated timestamps (autoCreateTime, autoUpdateTime)
NowFunc: func() time.Time {
return time.Now().UTC()
},
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err) return nil, fmt.Errorf("failed to open PostgreSQL database: %w", err)

View File

@@ -2,43 +2,44 @@ package store
import ( import (
"fmt" "fmt"
"strings" "strconv"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
) )
// TraderOrder order record // TraderOrder order record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderOrder struct { type TraderOrder struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"` ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"` ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"` Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"` PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
Type string `gorm:"column:type;not null" json:"type"` Type string `gorm:"column:type;not null" json:"type"`
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"` TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
Price float64 `gorm:"column:price;default:0" json:"price"` Price float64 `gorm:"column:price;default:0" json:"price"`
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"` StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"` Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"` FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"` AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
Commission float64 `gorm:"column:commission;default:0" json:"commission"` Commission float64 `gorm:"column:commission;default:0" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"` CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"` Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"` ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"` ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"` WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"` PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"` OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"` RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"` FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
} }
// TableName returns the table name for TraderOrder // TableName returns the table name for TraderOrder
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
} }
// TraderFill trade record // TraderFill trade record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderFill struct { type TraderFill struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"` OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"` ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"` ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"` Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
Price float64 `gorm:"column:price;not null" json:"price"` Price float64 `gorm:"column:price;not null" json:"price"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"` QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
Commission float64 `gorm:"column:commission;not null" json:"commission"` Commission float64 `gorm:"column:commission;not null" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"` CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"` IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
} }
// TableName returns the table name for TraderFill // TableName returns the table name for TraderFill
@@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error {
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col)) s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col))
} }
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
// Check if column is still timestamp type before migrating
timestampColumns := []struct{ table, col string }{
{"trader_orders", "created_at"},
{"trader_orders", "updated_at"},
{"trader_orders", "filled_at"},
{"trader_fills", "created_at"},
}
for _, c := range timestampColumns {
var dataType string
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.col).Scan(&dataType)
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
// Convert timestamp to Unix milliseconds (bigint)
s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col))
}
}
// Ensure indexes exist // Ensure indexes exist
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`)
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`)
@@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr
"filled_quantity": filledQty, "filled_quantity": filledQty,
"avg_fill_price": avgPrice, "avg_fill_price": avgPrice,
"commission": commission, "commission": commission,
"updated_at": time.Now().UTC().UnixMilli(),
} }
if status == "FILLED" { if status == "FILLED" {
updates["filled_at"] = time.Now() updates["filled_at"] = time.Now().UTC().UnixMilli()
} }
return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error
@@ -217,6 +237,27 @@ func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder
return orders, nil return orders, nil
} }
// GetTraderOrdersFiltered gets trader's order list with optional symbol and status filters
func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, status string, limit int) ([]*TraderOrder, error) {
var orders []*TraderOrder
query := s.db.Where("trader_id = ?", traderID)
if symbol != "" {
query = query.Where("symbol = ?", symbol)
}
if status != "" {
query = query.Where("status = ?", status)
}
err := query.Order("created_at DESC").
Limit(limit).
Find(&orders).Error
if err != nil {
return nil, fmt.Errorf("failed to query orders: %w", err)
}
return orders, nil
}
// GetOrderFills gets order's fill records // GetOrderFills gets order's fill records
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) { func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
var fills []*TraderFill var fills []*TraderFill
@@ -324,29 +365,59 @@ func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange // GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) { func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
type symbolMaxID struct { type symbolTradeID struct {
Symbol string Symbol string
MaxTradeID int64 ExchangeTradeID string
} }
var results []symbolMaxID var results []symbolTradeID
// Query all trade IDs grouped by symbol, find max in Go to avoid database-specific CAST issues
// (PostgreSQL INTEGER is 32-bit, can't handle Binance trade IDs > 2.1B)
err := s.db.Model(&TraderFill{}). err := s.db.Model(&TraderFill{}).
Select("symbol, MAX(CAST(exchange_trade_id AS INTEGER)) as max_trade_id"). Select("symbol, exchange_trade_id").
Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID). Where("exchange_id = ? AND exchange_trade_id != ''", exchangeID).
Group("symbol").
Find(&results).Error Find(&results).Error
if err != nil { if err != nil {
// If CAST fails (non-numeric trade IDs), fallback to string comparison return nil, fmt.Errorf("failed to query trade IDs: %w", err)
if strings.Contains(err.Error(), "CAST") || strings.Contains(err.Error(), "invalid") {
return make(map[string]int64), nil
}
return nil, fmt.Errorf("failed to query max trade IDs: %w", err)
} }
// Find max trade ID per symbol in Go (handles 64-bit integers properly)
result := make(map[string]int64) result := make(map[string]int64)
for _, r := range results { for _, r := range results {
result[r.Symbol] = r.MaxTradeID tradeID, err := strconv.ParseInt(r.ExchangeTradeID, 10, 64)
if err != nil {
continue // Skip non-numeric trade IDs
}
if tradeID > result[r.Symbol] {
result[r.Symbol] = tradeID
}
} }
return result, nil return result, nil
} }
// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange
// Used to recover sync state after service restart
func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) {
var fill TraderFill
err := s.db.Where("exchange_id = ?", exchangeID).
Order("created_at DESC").
First(&fill).Error
if err != nil {
return 0, err
}
return fill.CreatedAt, nil
}
// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms)
func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) {
var symbols []string
err := s.db.Model(&TraderFill{}).
Select("DISTINCT symbol").
Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs).
Pluck("symbol", &symbols).Error
if err != nil {
return nil, err
}
return symbols, nil
}

View File

@@ -25,30 +25,31 @@ type TraderStats struct {
} }
// TraderPosition position record // TraderPosition position record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderPosition struct { type TraderPosition struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"` TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"` ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"` ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"` ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"` Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"` Side string `gorm:"column:side;not null" json:"side"`
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"` EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"` Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"` EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"` EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
EntryTime time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"` ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"` ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"` RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
Fee float64 `gorm:"column:fee;default:0" json:"fee"` Fee float64 `gorm:"column:fee;default:0" json:"fee"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"` Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"` Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"` CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
Source string `gorm:"column:source;default:system" json:"source"` Source string `gorm:"column:source;default:system" json:"source"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
} }
// TableName returns the table name // TableName returns the table name
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
var tableExists int64 var tableExists int64
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists) s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
if tableExists > 0 { if tableExists > 0 {
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
// Check if column is still timestamp type before migrating
timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"}
for _, col := range timestampColumns {
var dataType string
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType)
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
// Convert timestamp to Unix milliseconds (bigint)
s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col))
}
}
// Just ensure index exists // Just ensure index exists
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`) s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`)
return nil return nil
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
// ClosePosition closes position // ClosePosition closes position
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error { func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
now := time.Now() nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": now, "exit_time": nowMs,
"realized_pnl": realizedPnL, "realized_pnl": realizedPnL,
"fee": fee, "fee": fee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": nowMs,
}).Error }).Error
} }
@@ -190,7 +204,8 @@ func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchang
} }
// ClosePositionFully marks position as fully closed // ClosePositionFully marks position as fully closed
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error { // exitTimeMs is Unix milliseconds UTC
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error {
var pos TraderPosition var pos TraderPosition
if err := s.db.First(&pos, id).Error; err != nil { if err := s.db.First(&pos, id).Error; err != nil {
return fmt.Errorf("failed to get position: %w", err) return fmt.Errorf("failed to get position: %w", err)
@@ -205,11 +220,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
"quantity": quantity, "quantity": quantity,
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": exitTime, "exit_time": exitTimeMs,
"realized_pnl": totalRealizedPnL, "realized_pnl": totalRealizedPnL,
"fee": totalFee, "fee": totalFee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error }).Error
} }
@@ -432,13 +448,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
EntryPrice: pos.EntryPrice, EntryPrice: pos.EntryPrice,
ExitPrice: pos.ExitPrice, ExitPrice: pos.ExitPrice,
RealizedPnL: pos.RealizedPnL, RealizedPnL: pos.RealizedPnL,
EntryTime: pos.EntryTime.Unix(), EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
} }
if pos.ExitTime != nil { if pos.ExitTime > 0 {
t.ExitTime = pos.ExitTime.Unix() t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
duration := pos.ExitTime.Sub(pos.EntryTime) durationMs := pos.ExitTime - pos.EntryTime
t.HoldDuration = formatDuration(duration) t.HoldDuration = formatDurationMs(durationMs)
} }
if pos.EntryPrice > 0 { if pos.EntryPrice > 0 {
@@ -457,26 +473,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
// formatDuration formats a duration // formatDuration formats a duration
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < time.Minute { return formatDurationMs(d.Milliseconds())
return fmt.Sprintf("%ds", int(d.Seconds())) }
// formatDurationMs formats a duration in milliseconds
func formatDurationMs(ms int64) string {
seconds := ms / 1000
minutes := seconds / 60
hours := minutes / 60
days := hours / 24
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
} }
if d < time.Hour { if minutes < 60 {
return fmt.Sprintf("%dm", int(d.Minutes())) return fmt.Sprintf("%dm", minutes)
} }
if d < 24*time.Hour { if hours < 24 {
hours := int(d.Hours()) remainingMins := minutes % 60
minutes := int(d.Minutes()) % 60 if remainingMins == 0 {
if minutes == 0 {
return fmt.Sprintf("%dh", hours) return fmt.Sprintf("%dh", hours)
} }
return fmt.Sprintf("%dh%dm", hours, minutes) return fmt.Sprintf("%dh%dm", hours, remainingMins)
} }
days := int(d.Hours()) / 24 remainingHours := hours % 24
hours := int(d.Hours()) % 24 if remainingHours == 0 {
if hours == 0 {
return fmt.Sprintf("%dd", days) return fmt.Sprintf("%dd", days)
} }
return fmt.Sprintf("%dd%dh", days, hours) return fmt.Sprintf("%dd%dh", days, remainingHours)
} }
// calculateSharpeRatioFromPnls calculates Sharpe ratio // calculateSharpeRatioFromPnls calculates Sharpe ratio
@@ -566,8 +590,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
s.WinTrades++ s.WinTrades++
} }
if pos.ExitTime != nil { if pos.ExitTime > 0 {
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes() holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins) symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
} }
} }
@@ -615,7 +639,7 @@ type HoldingTimeStats struct {
// GetHoldingTimeStats analyzes performance by holding duration // GetHoldingTimeStats analyzes performance by holding duration
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) { func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
var positions []TraderPosition var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query holding time stats: %w", err) return nil, fmt.Errorf("failed to query holding time stats: %w", err)
} }
@@ -632,10 +656,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
} }
for _, pos := range positions { for _, pos := range positions {
if pos.ExitTime == nil { if pos.ExitTime == 0 {
continue continue
} }
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours() holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
var rangeKey string var rangeKey string
switch { switch {
@@ -792,12 +816,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
// Calculate average holding time // Calculate average holding time
var positions []TraderPosition var positions []TraderPosition
s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions) s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
if len(positions) > 0 { if len(positions) > 0 {
var totalMins float64 var totalMins float64
for _, pos := range positions { for _, pos := range positions {
if pos.ExitTime != nil { if pos.ExitTime > 0 {
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes() totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
} }
} }
summary.AvgHoldingMins = totalMins / float64(len(positions)) summary.AvgHoldingMins = totalMins / float64(len(positions))
@@ -917,6 +941,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
} }
// ClosedPnLRecord represents a closed position record from exchange // ClosedPnLRecord represents a closed position record from exchange
// All time fields use int64 millisecond timestamps (UTC)
type ClosedPnLRecord struct { type ClosedPnLRecord struct {
Symbol string Symbol string
Side string Side string
@@ -926,8 +951,8 @@ type ClosedPnLRecord struct {
RealizedPnL float64 RealizedPnL float64
Fee float64 Fee float64
Leverage int Leverage int
EntryTime time.Time EntryTime int64 // Unix milliseconds UTC
ExitTime time.Time ExitTime int64 // Unix milliseconds UTC
OrderID string OrderID string
CloseType string CloseType string
ExchangeID string ExchangeID string
@@ -954,7 +979,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
exchangePositionID := record.ExchangeID exchangePositionID := record.ExchangeID
if exchangePositionID == "" { if exchangePositionID == "" {
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL) exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
} }
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID) exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
@@ -965,19 +990,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return false, nil return false, nil
} }
exitTime := record.ExitTime exitTimeMs := record.ExitTime
entryTime := record.EntryTime entryTimeMs := record.EntryTime
if exitTime.IsZero() || exitTime.Year() < 2000 { // Validate timestamps (must be after year 2000 = ~946684800000 ms)
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
if exitTimeMs < minValidTime {
return false, nil return false, nil
} }
if entryTime.IsZero() || entryTime.Year() < 2000 { if entryTimeMs < minValidTime {
entryTime = exitTime entryTimeMs = exitTimeMs
} }
if entryTime.After(exitTime) { if entryTimeMs > exitTimeMs {
entryTime = exitTime entryTimeMs = exitTimeMs
} }
nowMs := time.Now().UTC().UnixMilli()
pos := &TraderPosition{ pos := &TraderPosition{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -988,16 +1016,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
Quantity: record.Quantity, Quantity: record.Quantity,
EntryQuantity: record.Quantity, EntryQuantity: record.Quantity,
EntryPrice: record.EntryPrice, EntryPrice: record.EntryPrice,
EntryTime: entryTime, EntryTime: entryTimeMs,
ExitPrice: record.ExitPrice, ExitPrice: record.ExitPrice,
ExitOrderID: record.OrderID, ExitOrderID: record.OrderID,
ExitTime: &exitTime, ExitTime: exitTimeMs,
RealizedPnL: record.RealizedPnL, RealizedPnL: record.RealizedPnL,
Fee: record.Fee, Fee: record.Fee,
Leverage: record.Leverage, Leverage: record.Leverage,
Status: "CLOSED", Status: "CLOSED",
CloseReason: record.CloseType, CloseReason: record.CloseType,
Source: "sync", Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
} }
err = s.db.Create(pos).Error err = s.db.Create(pos).Error
@@ -1011,21 +1041,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return true, nil return true, nil
} }
// GetLastClosedPositionTime gets the most recent exit time // GetLastClosedPositionTime gets the most recent exit time (Unix ms)
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) { func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
var pos TraderPosition var pos TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED"). err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
Order("exit_time DESC"). Order("exit_time DESC").
First(&pos).Error First(&pos).Error
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil { if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
return time.Now().Add(-30 * 24 * time.Hour), nil return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
} }
if err != nil { if err != nil {
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err) return 0, fmt.Errorf("failed to get last closed position time: %w", err)
} }
return *pos.ExitTime, nil return pos.ExitTime, nil
} }
// CreateOpenPosition creates an open position // CreateOpenPosition creates an open position
@@ -1076,15 +1106,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
} }
// ClosePositionWithAccurateData closes a position with accurate data from exchange // ClosePositionWithAccurateData closes a position with accurate data from exchange
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error { // exitTimeMs is Unix milliseconds UTC
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error {
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{ return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"exit_price": exitPrice, "exit_price": exitPrice,
"exit_order_id": exitOrderID, "exit_order_id": exitOrderID,
"exit_time": exitTime, "exit_time": exitTimeMs,
"realized_pnl": realizedPnL, "realized_pnl": realizedPnL,
"fee": fee, "fee": fee,
"status": "CLOSED", "status": "CLOSED",
"close_reason": closeReason, "close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error }).Error
} }

View File

@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
} }
// ProcessTrade processes a single trade and updates position accordingly // ProcessTrade processes a single trade and updates position accordingly
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) ProcessTrade( func (pb *PositionBuilder) ProcessTrade(
traderID, exchangeID, exchangeType, symbol, side, action string, traderID, exchangeID, exchangeType, symbol, side, action string,
quantity, price, fee, realizedPnL float64, quantity, price, fee, realizedPnL float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
if strings.HasPrefix(action, "open_") { if strings.HasPrefix(action, "open_") {
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID) return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
} else if strings.HasPrefix(action, "close_") { } else if strings.HasPrefix(action, "close_") {
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID) return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
} }
return nil return nil
} }
// handleOpen handles opening positions (create new or average into existing) // handleOpen handles opening positions (create new or average into existing)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleOpen( func (pb *PositionBuilder) handleOpen(
traderID, exchangeID, exchangeType, symbol, side string, traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee float64, quantity, price, fee float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
// Get existing OPEN position for (symbol, side) // Get existing OPEN position for (symbol, side)
@@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen(
return fmt.Errorf("failed to get open position: %w", err) return fmt.Errorf("failed to get open position: %w", err)
} }
nowMs := time.Now().UTC().UnixMilli()
if existing == nil { if existing == nil {
// Create new position // Create new position
position := &TraderPosition{ position := &TraderPosition{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
ExchangeType: exchangeType, ExchangeType: exchangeType,
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()), ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
Symbol: symbol, Symbol: symbol,
Side: side, Side: side,
Quantity: quantity, Quantity: quantity,
EntryPrice: price, EntryPrice: price,
EntryOrderID: orderID, EntryOrderID: orderID,
EntryTime: tradeTime, EntryTime: tradeTimeMs,
Leverage: 1, Leverage: 1,
Status: "OPEN", Status: "OPEN",
Source: "sync", Source: "sync",
Fee: fee, Fee: fee,
CreatedAt: time.Now(), CreatedAt: nowMs,
UpdatedAt: time.Now(), UpdatedAt: nowMs,
} }
return pb.positionStore.CreateOpenPosition(position) return pb.positionStore.CreateOpenPosition(position)
} }
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
} }
// handleClose handles closing positions (partial or full) // handleClose handles closing positions (partial or full)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleClose( func (pb *PositionBuilder) handleClose(
traderID, exchangeID, exchangeType, symbol, side string, traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee, realizedPnL float64, quantity, price, fee, realizedPnL float64,
tradeTime time.Time, tradeTimeMs int64,
orderID string, orderID string,
) error { ) error {
// Get OPEN position // Get OPEN position
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
position.ID, position.ID,
finalExitPrice, finalExitPrice,
orderID, orderID,
tradeTime, tradeTimeMs,
totalPnL, totalPnL,
totalFee, totalFee,
"sync", "sync",

View File

@@ -328,7 +328,7 @@ func (s *StrategyStore) Update(strategy *Strategy) error {
"config": strategy.Config, "config": strategy.Config,
"is_public": strategy.IsPublic, "is_public": strategy.IsPublic,
"config_visible": strategy.ConfigVisible, "config_visible": strategy.ConfigVisible,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}).Error }).Error
} }

View File

@@ -123,7 +123,7 @@ func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error {
func (s *UserStore) UpdatePassword(userID, passwordHash string) error { func (s *UserStore) UpdatePassword(userID, passwordHash string) error {
return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ return s.db.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"password_hash": passwordHash, "password_hash": passwordHash,
"updated_at": time.Now(), "updated_at": time.Now().UTC(),
}).Error }).Error
} }

View File

@@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time) return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -68,7 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -85,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -96,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -113,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, IsMaker: false,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -125,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1407,10 +1407,16 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
result = append(result, trade) result = append(result, trade)
} }
return result, nil return result, nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Aster open orders
return []OpenOrder{}, nil
}

View File

@@ -637,7 +637,7 @@ func (at *AutoTrader) runCycle() error {
TakeProfit: d.TakeProfit, TakeProfit: d.TakeProfit,
Confidence: d.Confidence, Confidence: d.Confidence,
Reasoning: d.Reasoning, Reasoning: d.Reasoning,
Timestamp: time.Now(), Timestamp: time.Now().UTC(),
Success: false, Success: false,
} }
@@ -744,8 +744,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
// Priority 1: Get from database (trader_positions table) - most accurate // Priority 1: Get from database (trader_positions table) - most accurate
if at.store != nil { if at.store != nil {
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil { if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
if !dbPos.EntryTime.IsZero() { if dbPos.EntryTime > 0 {
updateTime = dbPos.EntryTime.UnixMilli() updateTime = dbPos.EntryTime
} }
} }
} }
@@ -1967,6 +1967,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
switch action { switch action {
case "open_long", "open_short": case "open_long", "open_short":
// Open position: create new position record // Open position: create new position record
nowMs := time.Now().UTC().UnixMilli()
pos := &store.TraderPosition{ pos := &store.TraderPosition{
TraderID: at.id, TraderID: at.id,
ExchangeID: at.exchangeID, // Exchange account UUID ExchangeID: at.exchangeID, // Exchange account UUID
@@ -1976,9 +1977,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
Quantity: quantity, Quantity: quantity,
EntryPrice: price, EntryPrice: price,
EntryOrderID: orderID, EntryOrderID: orderID,
EntryTime: time.Now(), EntryTime: nowMs,
Leverage: leverage, Leverage: leverage,
Status: "OPEN", Status: "OPEN",
CreatedAt: nowMs,
UpdatedAt: nowMs,
} }
if err := at.store.Position().Create(pos); err != nil { if err := at.store.Position().Create(pos); err != nil {
logger.Infof(" ⚠️ Failed to record position: %v", err) logger.Infof(" ⚠️ Failed to record position: %v", err)
@@ -1996,7 +1999,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
at.id, at.exchangeID, at.exchange, at.id, at.exchangeID, at.exchange,
symbol, side, action, symbol, side, action,
quantity, price, fee, 0, // realizedPnL will be calculated quantity, price, fee, 0, // realizedPnL will be calculated
time.Now(), orderID, time.Now().UTC().UnixMilli(), orderID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to process close position: %v", err) logger.Infof(" ⚠️ Failed to process close position: %v", err)
} else { } else {
@@ -2049,8 +2052,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
ReduceOnly: reduceOnly, ReduceOnly: reduceOnly,
ClosePosition: reduceOnly, ClosePosition: reduceOnly,
OrderAction: orderAction, OrderAction: orderAction,
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
UpdatedAt: time.Now(), UpdatedAt: time.Now().UTC().UnixMilli(),
} }
} }
@@ -2091,7 +2094,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, // Will be calculated for close orders RealizedPnL: 0, // Will be calculated for close orders
IsMaker: false, // Market orders are usually taker IsMaker: false, // Market orders are usually taker
CreatedAt: time.Now(), CreatedAt: time.Now().UTC().UnixMilli(),
} }
// Calculate realized PnL for close orders // Calculate realized PnL for close orders
@@ -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)
}

View File

@@ -776,6 +776,64 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error {
return nil return nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *FuturesTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// 1. Get legacy open orders
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
for _, order := range orders {
price, _ := strconv.ParseFloat(order.Price, 64)
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
quantity, _ := strconv.ParseFloat(order.OrigQuantity, 64)
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.OrderID),
Symbol: order.Symbol,
Side: string(order.Side),
PositionSide: string(order.PositionSide),
Type: string(order.Type),
Price: price,
StopPrice: stopPrice,
Quantity: quantity,
Status: string(order.Status),
})
}
// 2. Get Algo orders (new API for stop-loss/take-profit)
algoOrders, err := t.client.NewListOpenAlgoOrdersService().
Symbol(symbol).
Do(context.Background())
if err == nil {
for _, algoOrder := range algoOrders {
triggerPrice, _ := strconv.ParseFloat(algoOrder.TriggerPrice, 64)
quantity, _ := strconv.ParseFloat(algoOrder.Quantity, 64)
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", algoOrder.AlgoId),
Symbol: algoOrder.Symbol,
Side: string(algoOrder.Side),
PositionSide: string(algoOrder.PositionSide),
Type: string(algoOrder.OrderType),
Price: 0, // Algo orders use stop price
StopPrice: triggerPrice,
Quantity: quantity,
Status: "NEW",
})
}
}
return result, nil
}
// GetMarketPrice gets market price // GetMarketPrice gets market price
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) { func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background()) prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
@@ -1122,7 +1180,7 @@ func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord
TradeID: strconv.FormatInt(income.TranID, 10), TradeID: strconv.FormatInt(income.TranID, 10),
Symbol: income.Symbol, Symbol: income.Symbol,
RealizedPnL: pnl, RealizedPnL: pnl,
Time: time.UnixMilli(income.Time), Time: time.UnixMilli(income.Time).UTC(),
// Note: Income API doesn't provide price, quantity, side, fee // Note: Income API doesn't provide price, quantity, side, fee
// For accurate data, use GetTradesForSymbol with specific symbol // For accurate data, use GetTradesForSymbol with specific symbol
} }
@@ -1167,7 +1225,7 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -1210,7 +1268,7 @@ func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, li
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(at.Time), Time: time.UnixMilli(at.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -1244,3 +1302,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
return symbols, nil return symbols, nil
} }
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
incomes, err := t.client.NewGetIncomeHistoryService().
IncomeType("REALIZED_PNL").
StartTime(lastSyncTime.UnixMilli()).
Limit(1000).
Do(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get PnL history: %w", err)
}
symbolMap := make(map[string]bool)
for _, income := range incomes {
if income.Symbol != "" {
symbolMap[income.Symbol] = true
}
}
var symbols []string
for symbol := range symbolMap {
symbols = append(symbols, symbol)
}
return symbols, nil
}

View File

@@ -11,9 +11,9 @@ import (
"time" "time"
) )
// syncState stores the last sync time for incremental sync // syncState stores the last sync time (Unix ms) for incremental sync
var ( var (
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
binanceSyncStateMutex sync.RWMutex binanceSyncStateMutex sync.RWMutex
) )
@@ -25,42 +25,106 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
return fmt.Errorf("store is nil") return fmt.Errorf("store is nil")
} }
// Get last sync time (default to 24 hours ago for first sync) orderStore := st.Order()
// Get last sync time (Unix ms) - first try memory, then database, then default
binanceSyncStateMutex.RLock() binanceSyncStateMutex.RLock()
lastSyncTime, exists := binanceSyncState[exchangeID] lastSyncTimeMs, exists := binanceSyncState[exchangeID]
binanceSyncStateMutex.RUnlock() binanceSyncStateMutex.RUnlock()
nowMs := time.Now().UTC().UnixMilli()
if !exists { if !exists {
lastSyncTime = time.Now().Add(-24 * time.Hour) // Try to get last fill time from database (persist across restarts)
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
if err == nil && lastFillTimeMs > 0 {
// If recovered time is in the future, it's clearly wrong - use default
if lastFillTimeMs > nowMs {
logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default",
lastFillTimeMs, nowMs)
lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago
} else {
// Add 1 second buffer to avoid re-fetching the same fill
lastSyncTimeMs = lastFillTimeMs + 1000
logger.Infof("📅 Recovered last sync time from DB: %s (UTC)",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
}
} else {
// First sync: go back 24 hours
lastSyncTimeMs = nowMs - 24*60*60*1000
logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)",
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
}
} }
// Record current time BEFORE querying, to avoid missing trades during sync // Record current time BEFORE querying, to avoid missing trades during sync
// This prevents race condition where trades happen between query and lastSyncTime update // 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 // Step 1: Get max trade IDs from local DB for incremental sync
orderStore := st.Order()
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID) maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
if err != nil { if err != nil {
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err) logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
maxTradeIDs = make(map[string]int64) maxTradeIDs = make(map[string]int64)
} }
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call) // Step 2: Detect symbols to sync using multiple methods
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime) // COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades)
symbolMap := make(map[string]bool)
lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls
// Method 1: COMMISSION income detection
commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime)
if err != nil { if err != nil {
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err) logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
// Fallback: only sync symbols with active positions } else {
changedSymbols = t.getPositionSymbols() logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols)
for _, s := range commissionSymbols {
symbolMap[s] = true
}
}
// Method 2: Always include active positions (catches trades that COMMISSION missed)
positionSymbols := t.getPositionSymbols()
logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols)
for _, s := range positionSymbols {
symbolMap[s] = true
}
// Method 3: Include symbols from recent fills in DB (in case some were partially synced)
recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs)
logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols)
for _, s := range recentSymbols {
symbolMap[s] = true
}
// Method 4: 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 { if len(changedSymbols) == 0 {
logger.Infof("📭 No symbols with new trades to sync") logger.Infof("📭 No symbols with new trades to sync")
// Update last sync time even if no changes // Update last sync time even if no changes
binanceSyncStateMutex.Lock() binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTime binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock() binanceSyncStateMutex.Unlock()
return nil 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 // This prevents data loss when some symbols fail due to rate limit or network issues
if len(failedSymbols) == 0 { if len(failedSymbols) == 0 {
binanceSyncStateMutex.Lock() binanceSyncStateMutex.Lock()
binanceSyncState[exchangeID] = syncStartTime binanceSyncState[exchangeID] = syncStartTimeMs
binanceSyncStateMutex.Unlock() binanceSyncStateMutex.Unlock()
} else { } else {
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols) 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 trades by time ASC (oldest first) for proper position building
sort.Slice(allTrades, func(i, j int) bool { sort.Slice(allTrades, func(i, j int) bool {
return allTrades[i].Time.Before(allTrades[j].Time) return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli()
}) })
// Process trades one by one // Process trades one by one
@@ -145,7 +209,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
// Normalize side // Normalize side
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -162,9 +227,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -173,7 +238,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
@@ -190,7 +255,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, IsMaker: false,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -202,7 +267,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {
@@ -210,8 +275,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
} }
syncedCount++ syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)",
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction) trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction,
trade.Time.UTC().Format("01-02 15:04:05"))
} }
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount) 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 // StartOrderSync starts background order sync task for Binance
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) { func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
// Run first sync immediately
go func() {
logger.Infof("🔄 Running initial Binance order sync...")
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
}
}()
// Then run periodically
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
go func() { go func() {
for range ticker.C { for range ticker.C {

View 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))
}
}
}

View 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)
}

View 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)
}

View File

@@ -110,7 +110,7 @@ func (t *BitgetTrader) GetTrades(startTime time.Time, limit int) ([]BitgetTrade,
FillQty: fillQty, FillQty: fillQty,
Fee: -fee, // Bitget returns negative fee Fee: -fee, // Bitget returns negative fee
FeeAsset: fill.FeeCcy, FeeAsset: fill.FeeCcy,
ExecTime: time.UnixMilli(cTime), ExecTime: time.UnixMilli(cTime).UTC(),
ProfitLoss: profit, ProfitLoss: profit,
OrderType: "MARKET", OrderType: "MARKET",
OrderAction: orderAction, OrderAction: orderAction,
@@ -146,7 +146,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.Before(trades[j].ExecTime) return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -174,7 +174,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -191,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
FilledQuantity: trade.FillQty, FilledQuantity: trade.FillQty,
AvgFillPrice: trade.FillPrice, AvgFillPrice: trade.FillPrice,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.ExecTime, FilledAt: execTimeMs,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
UpdatedAt: trade.ExecTime, UpdatedAt: execTimeMs,
} }
// Insert order record // Insert order record
@@ -202,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
continue continue
} }
// Create fill record // Create fill record - use UTC time in milliseconds
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -219,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
CommissionAsset: trade.FeeAsset, CommissionAsset: trade.FeeAsset,
RealizedPnL: trade.ProfitLoss, RealizedPnL: trade.ProfitLoss,
IsMaker: false, IsMaker: false,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -231,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
trade.ExecTime, trade.TradeID, execTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1069,8 +1069,8 @@ func (t *BitgetTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnL
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime) record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime) record.ExitTime = time.UnixMilli(uTime).UTC()
record.CloseType = "unknown" record.CloseType = "unknown"
records = append(records, record) records = append(records, record)
@@ -1096,3 +1096,9 @@ func genBitgetClientOid() string {
rand := time.Now().Nanosecond() % 100000 rand := time.Now().Nanosecond() % 100000
return fmt.Sprintf("nofx%d%05d", timestamp, rand) return fmt.Sprintf("nofx%d%05d", timestamp, rand)
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
// TODO: Implement Bitget open orders
return []OpenOrder{}, nil
}

View File

@@ -127,7 +127,7 @@ func (t *BybitTrader) parseTradesResult(list []map[string]interface{}) ([]BybitT
closedSize, _ := strconv.ParseFloat(closedSizeStr, 64) closedSize, _ := strconv.ParseFloat(closedSizeStr, 64)
closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64) closedPnl, _ := strconv.ParseFloat(closedPnlStr, 64)
execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64) execTimeMs, _ := strconv.ParseInt(execTimeStr, 10, 64)
execTime := time.UnixMilli(execTimeMs) execTime := time.UnixMilli(execTimeMs).UTC()
// Determine order action based on side and closedSize // Determine order action based on side and closedSize
// If closedSize > 0, it's a close trade // If closedSize > 0, it's a close trade
@@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.Before(trades[j].ExecTime) return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -223,7 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -240,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
FilledQuantity: trade.ExecQty, FilledQuantity: trade.ExecQty,
AvgFillPrice: trade.ExecPrice, AvgFillPrice: trade.ExecPrice,
Commission: trade.ExecFee, Commission: trade.ExecFee,
FilledAt: trade.ExecTime, FilledAt: execTimeMs,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
UpdatedAt: trade.ExecTime, UpdatedAt: execTimeMs,
} }
// Insert order record // Insert order record
@@ -251,7 +252,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
continue continue
} }
// Create fill record // Create fill record - use UTC time
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -268,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.ClosedPnL, RealizedPnL: trade.ClosedPnL,
IsMaker: trade.IsMaker, IsMaker: trade.IsMaker,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -280,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL, trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
trade.ExecTime, trade.ExecID, execTimeMs, trade.ExecID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
} else { } else {

View File

@@ -1032,8 +1032,8 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
RealizedPnL: closedPnL, RealizedPnL: closedPnL,
Fee: fee, Fee: fee,
Leverage: int(leverage), Leverage: int(leverage),
EntryTime: time.UnixMilli(createdTime), EntryTime: time.UnixMilli(createdTime).UTC(),
ExitTime: time.UnixMilli(updatedTime), ExitTime: time.UnixMilli(updatedTime).UTC(),
OrderID: orderId, OrderID: orderId,
CloseType: "unknown", // Bybit doesn't provide close type directly CloseType: "unknown", // Bybit doesn't provide close type directly
ExchangeID: orderId, // Use orderId as exchange ID ExchangeID: orderId, // Use orderId as exchange ID
@@ -1044,3 +1044,64 @@ func (t *BybitTrader) parseClosedPnLResult(resultData interface{}) ([]ClosedPnLR
return records, nil return records, nil
} }
// GetOpenOrders gets all open/pending orders for a symbol
func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
var result []OpenOrder
// Get conditional orders (stop-loss, take-profit)
params := map[string]interface{}{
"category": "linear",
"symbol": symbol,
"orderFilter": "StopOrder",
}
resp, err := t.client.NewUtaBybitServiceWithParams(params).GetOpenOrders(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
if resp.RetCode == 0 {
resultData, ok := resp.Result.(map[string]interface{})
if ok {
list, _ := resultData["list"].([]interface{})
for _, item := range list {
order, ok := item.(map[string]interface{})
if !ok {
continue
}
orderId, _ := order["orderId"].(string)
sym, _ := order["symbol"].(string)
side, _ := order["side"].(string)
orderType, _ := order["orderType"].(string)
stopOrderType, _ := order["stopOrderType"].(string)
triggerPrice, _ := order["triggerPrice"].(string)
qty, _ := order["qty"].(string)
price, _ := strconv.ParseFloat(triggerPrice, 64)
quantity, _ := strconv.ParseFloat(qty, 64)
// Determine type based on stopOrderType
displayType := orderType
if stopOrderType != "" {
displayType = stopOrderType
}
result = append(result, OpenOrder{
OrderID: orderId,
Symbol: sym,
Side: side,
PositionSide: "", // Bybit doesn't use positionSide for UTA
Type: displayType,
Price: 0,
StopPrice: price,
Quantity: quantity,
Status: "NEW",
})
}
}
}
return result, nil
}

View File

@@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time) return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -61,7 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
positionSide = "SHORT" positionSide = "SHORT"
} }
// Create order record // Create order record - use Unix milliseconds UTC
tradeTimeMs := trade.Time.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -78,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -89,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -106,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -118,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1402,15 +1402,12 @@ func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64,
}, },
} }
// Create OrderAction with builder (xyz dex requires builder info for order routing) // Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{ action := hyperliquid.OrderAction{
Type: "order", Type: "order",
Orders: []hyperliquid.OrderWire{orderWire}, Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na", Grouping: "na",
Builder: &hyperliquid.BuilderInfo{ Builder: nil,
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
},
} }
// Sign the action // Sign the action
@@ -1592,15 +1589,12 @@ func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size f
}, },
} }
// Create OrderAction with builder // Create OrderAction (no builder to avoid requiring builder fee approval)
action := hyperliquid.OrderAction{ action := hyperliquid.OrderAction{
Type: "order", Type: "order",
Orders: []hyperliquid.OrderWire{orderWire}, Orders: []hyperliquid.OrderWire{orderWire},
Grouping: "na", Grouping: "na",
Builder: &hyperliquid.BuilderInfo{ Builder: nil,
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
Fee: 10,
},
} }
// Sign the action // Sign the action
@@ -2070,7 +2064,7 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: pnl,
Fee: fee, Fee: fee,
Time: time.UnixMilli(fill.Time), Time: time.UnixMilli(fill.Time).UTC(),
} }
trades = append(trades, trade) trades = append(trades, trade)
} }
@@ -2079,7 +2073,44 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe
} }
// defaultBuilder is the builder info for order routing // defaultBuilder is the builder info for order routing
var defaultBuilder = &hyperliquid.BuilderInfo{ // Set to nil to avoid requiring builder fee approval
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", //
Fee: 10, // var defaultBuilder = &hyperliquid.BuilderInfo{
// Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
// Fee: 10,
// }
var defaultBuilder *hyperliquid.BuilderInfo = nil
// GetOpenOrders gets all open/pending orders for a symbol
func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return nil, fmt.Errorf("failed to get open orders: %w", err)
}
var result []OpenOrder
for _, order := range openOrders {
if order.Coin != symbol {
continue
}
side := "BUY"
if order.Side == "A" {
side = "SELL"
}
result = append(result, OpenOrder{
OrderID: fmt.Sprintf("%d", order.Oid),
Symbol: order.Coin,
Side: side,
PositionSide: "",
Type: "LIMIT",
Price: order.LimitPx,
StopPrice: 0,
Quantity: order.Size,
Status: "NEW",
})
}
return result, nil
} }

View File

@@ -94,4 +94,21 @@ type Trader interface {
// limit: max number of records to return // limit: max number of records to return
// Returns accurate exit price, fees, and close reason for positions closed externally // Returns accurate exit price, fees, and close reason for positions closed externally
GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error)
// GetOpenOrders Get open/pending orders from exchange
// Returns stop-loss, take-profit, and limit orders that haven't been filled
GetOpenOrders(symbol string) ([]OpenOrder, error)
}
// OpenOrder represents a pending order on the exchange
type OpenOrder struct {
OrderID string `json:"order_id"`
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT
Type string `json:"type"` // LIMIT/STOP_MARKET/TAKE_PROFIT_MARKET
Price float64 `json:"price"` // Order price (for limit orders)
StopPrice float64 `json:"stop_price"` // Trigger price (for stop orders)
Quantity float64 `json:"quantity"`
Status string `json:"status"` // NEW
} }

View File

@@ -34,7 +34,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
// Sort trades by time ASC (oldest first) for proper position building // Sort trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].Time.Before(trades[j].Time) return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -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{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -87,9 +88,9 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
FilledQuantity: trade.Quantity, FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price, AvgFillPrice: trade.Price,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.Time, FilledAt: tradeTimeMs,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
UpdatedAt: trade.Time, UpdatedAt: tradeTimeMs,
} }
// Insert order record // Insert order record
@@ -98,7 +99,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
continue continue
} }
// Create fill record // Create fill record - use Unix milliseconds UTC
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -115,7 +116,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL, RealizedPnL: trade.RealizedPnL,
IsMaker: false, IsMaker: false,
CreatedAt: trade.Time, CreatedAt: tradeTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -127,7 +128,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, tradeTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -537,7 +537,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
// - signChanged with position flip: split into close + open // - signChanged with position flip: split into close + open
const EPSILON = 0.0001 const EPSILON = 0.0001
tradeTime := time.UnixMilli(lt.Timestamp) tradeTime := time.UnixMilli(lt.Timestamp).UTC()
// Calculate position after trade // Calculate position after trade
var posAfter float64 var posAfter float64
@@ -628,7 +628,7 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
Quantity: qty, Quantity: qty,
RealizedPnL: 0, // Not available in API RealizedPnL: 0, // Not available in API
Fee: fee, Fee: fee,
Time: time.UnixMilli(lt.Timestamp), Time: time.UnixMilli(lt.Timestamp).UTC(),
} }
result = append(result, trade) result = append(result, trade)
} }

View File

@@ -686,3 +686,9 @@ func pow10(n int) int64 {
} }
return result 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
}

View File

@@ -133,7 +133,7 @@ func (t *OKXTrader) GetTrades(startTime time.Time, limit int) ([]OKXTrade, error
FillQtyBase: fillQtyBase, FillQtyBase: fillQtyBase,
Fee: -fee, // OKX returns negative fee Fee: -fee, // OKX returns negative fee
FeeAsset: fill.FeeCcy, FeeAsset: fill.FeeCcy,
ExecTime: time.UnixMilli(ts), ExecTime: time.UnixMilli(ts).UTC(),
IsMaker: fill.ExecType == "M", IsMaker: fill.ExecType == "M",
OrderType: "MARKET", OrderType: "MARKET",
OrderAction: orderAction, 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 trades by time ASC (oldest first) for proper position building
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
return trades[i].ExecTime.Before(trades[j].ExecTime) return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
}) })
// Process trades one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
@@ -197,7 +197,8 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
// Normalize side for storage // Normalize side for storage
side := strings.ToUpper(trade.Side) side := strings.ToUpper(trade.Side)
// Create order record // Create order record - use UTC time in milliseconds to avoid timezone issues
execTimeMs := trade.ExecTime.UTC().UnixMilli()
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -214,9 +215,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
FilledQuantity: trade.FillQtyBase, FilledQuantity: trade.FillQtyBase,
AvgFillPrice: trade.FillPrice, AvgFillPrice: trade.FillPrice,
Commission: trade.Fee, Commission: trade.Fee,
FilledAt: trade.ExecTime, FilledAt: execTimeMs,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
UpdatedAt: trade.ExecTime, UpdatedAt: execTimeMs,
} }
// Insert order record // Insert order record
@@ -225,7 +226,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
continue continue
} }
// Create fill record // Create fill record - use UTC time in milliseconds
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
@@ -242,7 +243,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
CommissionAsset: trade.FeeAsset, CommissionAsset: trade.FeeAsset,
RealizedPnL: 0, // OKX fills don't include PnL per trade RealizedPnL: 0, // OKX fills don't include PnL per trade
IsMaker: trade.IsMaker, IsMaker: trade.IsMaker,
CreatedAt: trade.ExecTime, CreatedAt: execTimeMs,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
@@ -254,7 +255,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX
trade.ExecTime, trade.TradeID, execTimeMs, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else { } else {

View File

@@ -1366,8 +1366,8 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
// Times // Times
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64) cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64) uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
record.EntryTime = time.UnixMilli(cTime) record.EntryTime = time.UnixMilli(cTime).UTC()
record.ExitTime = time.UnixMilli(uTime) record.ExitTime = time.UnixMilli(uTime).UTC()
// Close type // Close type
switch pos.Type { switch pos.Type {
@@ -1387,3 +1387,9 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
return records, nil 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
}

View File

@@ -40,7 +40,7 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
logger.Infof("📥 Found %d positions on exchange", len(positions)) logger.Infof("📥 Found %d positions on exchange", len(positions))
// Step 3: Create snapshot record for each position // Step 3: Create snapshot record for each position
now := time.Now() nowMs := time.Now().UnixMilli()
createdCount := 0 createdCount := 0
for _, posMap := range positions { for _, posMap := range positions {
@@ -74,18 +74,18 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, ExchangeID: exchangeID,
ExchangeType: exchangeType, 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, Symbol: symbol,
Side: side, Side: side,
Quantity: positionAmt, Quantity: positionAmt,
EntryPrice: entryPrice, EntryPrice: entryPrice,
EntryOrderID: "snapshot", // Mark as snapshot EntryOrderID: "snapshot", // Mark as snapshot
EntryTime: now, EntryTime: nowMs,
Leverage: int(leverage), Leverage: int(leverage),
Status: "OPEN", Status: "OPEN",
Source: "snapshot", // Mark source as snapshot Source: "snapshot", // Mark source as snapshot
CreatedAt: now, CreatedAt: nowMs,
UpdatedAt: now, UpdatedAt: nowMs,
} }
if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil { if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil {

View File

@@ -1,7 +1,6 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
// Force HMR Update - Reliability Fix v3 (Emergency Recovery) import useSWR from 'swr'
import useSWR, { mutate } from 'swr'
import { api } from './lib/api' import { api } from './lib/api'
import { TraderDashboardPage } from './pages/TraderDashboardPage' import { TraderDashboardPage } from './pages/TraderDashboardPage'
@@ -20,13 +19,11 @@ import HeaderBar from './components/HeaderBar'
import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ConfirmDialogProvider } from './components/ConfirmDialog' import { ConfirmDialogProvider } from './components/ConfirmDialog'
import { t, type Language } from './i18n/translations' import { t } from './i18n/translations'
import { confirmToast, notify } from './lib/notify'
import { useSystemConfig } from './hooks/useSystemConfig' import { useSystemConfig } from './hooks/useSystemConfig'
import { OFFICIAL_LINKS } from './constants/branding' import { OFFICIAL_LINKS } from './constants/branding'
import { BacktestPage } from './components/BacktestPage' import { BacktestPage } from './components/BacktestPage'
import { LogOut, Loader2 } from 'lucide-react'
import type { import type {
SystemStatus, SystemStatus,
AccountInfo, AccountInfo,

View File

@@ -31,6 +31,19 @@ interface OrderMarker {
symbol: string 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 { interface AdvancedChartProps {
symbol: string symbol: string
interval?: string interval?: string
@@ -101,6 +114,7 @@ export function AdvancedChart({
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5 const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据 const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据
const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据 const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据
const priceLinesRef = useRef<any[]>([]) // 存储挂单价格线
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -179,9 +193,15 @@ export function AdvancedChart({
return 0 return 0
} }
// 如果已经是数字Unix 时间戳),直接返回 // 如果已经是数字Unix 时间戳)
if (typeof time === 'number') { 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 return time
} }
@@ -221,8 +241,8 @@ export function AdvancedChart({
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => { const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
try { try {
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol) console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
// 获取已成交的订单,限制50条避免标记太多重叠 // 获取已成交的订单,增加到200条以显示更多历史订单
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`) const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=200`)
console.log('[AdvancedChart] Orders API response:', result) 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(() => { useEffect(() => {
if (!chartContainerRef.current) return if (!chartContainerRef.current) return
const chart = createChart(chartContainerRef.current, { const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth, width: chartContainerRef.current.clientWidth || 800,
height: height, height: chartContainerRef.current.clientHeight || height,
layout: { layout: {
background: { color: '#0B0E11' }, background: { color: '#0B0E11' },
textColor: '#B7BDC6', textColor: '#B7BDC6',
@@ -407,16 +447,16 @@ export function AdvancedChart({
}) })
volumeSeriesRef.current = volumeSeries as any volumeSeriesRef.current = volumeSeries as any
// 响应式调整 // 响应式调整 (ResizeObserver)
const handleResize = () => { const resizeObserver = new ResizeObserver((entries) => {
if (chartContainerRef.current && chartRef.current) { if (entries.length === 0 || !entries[0].contentRect) return
chartRef.current.applyOptions({ const { width, height } = entries[0].contentRect
width: chartContainerRef.current.clientWidth, chart.applyOptions({ width, height })
}) })
}
}
window.addEventListener('resize', handleResize) if (chartContainerRef.current) {
resizeObserver.observe(chartContainerRef.current)
}
// 监听鼠标移动,显示 OHLC 信息 // 监听鼠标移动,显示 OHLC 信息
chart.subscribeCrosshairMove((param) => { chart.subscribeCrosshairMove((param) => {
@@ -450,10 +490,11 @@ export function AdvancedChart({
}) })
return () => { return () => {
window.removeEventListener('resize', handleResize) resizeObserver.disconnect()
chart.remove() chart.remove()
} }
}, [height]) }, []) // Chart is created once, ResizeObserver handles dimension changes
// 加载数据和指标 // 加载数据和指标
useEffect(() => { useEffect(() => {
@@ -580,15 +621,8 @@ export function AdvancedChart({
return klineTimes[left] return klineTimes[left]
} }
// 过滤并对齐订单到 K 线时间 // 按 K 线时间分组统计订单
const markers: Array<{ const ordersByCandle = new Map<number, { buys: number; sells: number }>()
time: Time
position: 'belowBar'
color: string
shape: 'circle'
text: string
size: number
}> = []
orders.forEach(order => { orders.forEach(order => {
// 使用二分查找找到对应的 K 线蜡烛时间 // 使用二分查找找到对应的 K 线蜡烛时间
@@ -600,15 +634,48 @@ export function AdvancedChart({
return return
} }
const isBuy = order.rawSide === 'buy' const existing = ordersByCandle.get(candleTime) || { buys: 0, sells: 0 }
markers.push({ if (order.rawSide === 'buy') {
time: candleTime as Time, existing.buys++
position: 'belowBar' as const, } else {
color: isBuy ? '#0ECB81' : '#F6465D', existing.sells++
shape: 'circle' as const, }
text: isBuy ? 'B' : 'S', ordersByCandle.set(candleTime, existing)
size: 1, })
})
// 为每个有订单的 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 要求标记按时间顺序) // 按时间排序lightweight-charts 要求标记按时间顺序)
@@ -674,6 +741,87 @@ export function AdvancedChart({
return () => clearInterval(refreshInterval) return () => clearInterval(refreshInterval)
}, [symbol, interval, traderID, exchange]) }, [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(() => { useEffect(() => {
if (!seriesMarkersRef.current) return if (!seriesMarkersRef.current) return
@@ -767,12 +915,15 @@ export function AdvancedChart({
borderRadius: '12px', borderRadius: '12px',
overflow: 'hidden', overflow: 'hidden',
border: '1px solid rgba(43, 49, 57, 0.5)', border: '1px solid rgba(43, 49, 57, 0.5)',
height: '100%',
display: 'flex',
flexDirection: 'column',
}} }}
> >
{/* Compact Professional Header */} {/* Compact Professional Header */}
<div <div
className="flex items-center justify-between px-4 py-2" 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 */} {/* Left: Symbol Info + Price */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -929,8 +1080,8 @@ export function AdvancedChart({
)} )}
{/* 图表容器 */} {/* 图表容器 */}
<div style={{ position: 'relative' }}> <div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
<div ref={chartContainerRef} /> <div ref={chartContainerRef} style={{ height: '100%', width: '100%' }} />
{/* OHLC Tooltip */} {/* OHLC Tooltip */}
{tooltipData && ( {tooltipData && (

View File

@@ -1486,7 +1486,7 @@ export function BacktestPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div> <div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}> <label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.feeLabel')} {tr('form.feeLabel')}

View File

@@ -145,14 +145,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
console.log('[ChartTabs] rendering, activeTab:', activeTab) console.log('[ChartTabs] rendering, activeTab:', activeTab)
return ( return (
<div className="nofx-glass rounded-lg border border-white/5 relative z-10 w-full h-[600px] flex flex-col"> <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]'
{/* Clean Professional Toolbar */} }`}>
{/*
Premium Professional Toolbar
Mobile: Single row, horizontal scroll with gradient mask
Desktop: Standard flex-wrap/nowrap
*/}
<div <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" 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)' }} style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
> >
{/* Left: Tab Switcher */} {/* Left: Tab Switcher */}
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<button <button
onClick={() => setActiveTab('equity')} 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' 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" /> <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>
<button <button
@@ -172,33 +178,31 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
}`} }`}
> >
<CandlestickChart className="w-3.5 h-3.5" /> <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> </button>
{/* Market Type Pills - Only when kline active */} {/* Market Type Pills - Only when kline active, HIDDEN on mobile to save space */}
{activeTab === 'kline' && ( {activeTab === 'kline' && (
<> <div className="hidden md:flex items-center gap-1 ml-2 border-l border-white/10 pl-2">
<div className="w-px h-4 bg-white/10 mx-2" /> {(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
<div className="flex items-center gap-1"> const config = MARKET_CONFIG[type]
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => { const isActive = marketType === type
const config = MARKET_CONFIG[type] return (
const isActive = marketType === type <button
return ( key={type}
<button onClick={() => handleMarketTypeChange(type)}
key={type} className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
onClick={() => handleMarketTypeChange(type)} ? 'bg-white/10 text-white border-white/20'
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive : 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
? '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}
<span className="mr-1 opacity-70">{config.icon}</span> </button>
{language === 'zh' ? config.label.zh : config.label.en} )
</button> })}
) </div>
})}
</div>
</>
)} )}
</div> </div>
@@ -294,8 +298,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
)} )}
</div> </div>
{/* Tab Content */} {/* Tab Content - Chart autosizes to this container */}
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden"> <div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden h-full min-h-0">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{activeTab === 'equity' ? ( {activeTab === 'equity' ? (
<motion.div <motion.div
@@ -321,8 +325,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
symbol={chartSymbol} symbol={chartSymbol}
interval={interval} interval={interval}
traderID={traderId} traderID={traderId}
// Dynamic height to fill container // Dynamic auto-sizing via ResizeObserver
height={550}
exchange={currentExchange} exchange={currentExchange}
onSymbolChange={setChartSymbol} onSymbolChange={setChartSymbol}
/> />

View File

@@ -63,9 +63,15 @@ export function ChartWithOrders({
return 0 return 0
} }
// 如果已经是数字Unix 时间戳),直接返回 // 如果已经是数字Unix 时间戳)
if (typeof time === 'number') { 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 return time
} }

View File

@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import { motion } from 'framer-motion'
interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> { interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode children?: React.ReactNode

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' 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 { Menu, X, ChevronDown } from 'lucide-react'
import { t, type Language } from '../i18n/translations' import { t, type Language } from '../i18n/translations'
import { useSystemConfig } from '../hooks/useSystemConfig' import { useSystemConfig } from '../hooks/useSystemConfig'
@@ -306,209 +306,170 @@ export default function HeaderBar({
</motion.button> </motion.button>
</div> </div>
{/* Mobile Menu */} {/* Mobile Menu Overlay */}
<motion.div <AnimatePresence>
initial={false} {mobileMenuOpen && (
animate={ <motion.div
mobileMenuOpen initial={{ opacity: 0 }}
? { height: 'auto', opacity: 1 } animate={{ opacity: 1 }}
: { height: 0, opacity: 0 } exit={{ opacity: 0 }}
} transition={{ duration: 0.2 }}
transition={{ duration: 0.3 }} className="fixed inset-0 z-40 md:hidden bg-black/90 backdrop-blur-xl"
className="md:hidden overflow-hidden bg-nofx-bg-lighter border-t border-nofx-gold/10" style={{ top: '64px' }} // Below header
> >
<div className="px-4 py-4 space-y-2"> <motion.div
{/* Mobile Navigation Tabs - Show all tabs */} initial={{ y: -20, opacity: 0 }}
{(() => { animate={{ y: 0, opacity: 1 }}
const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [ transition={{ delay: 0.1, duration: 0.3 }}
{ page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true }, className="flex flex-col h-[calc(100vh-64px)] overflow-y-auto px-6 py-8"
{ 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"
> >
<svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"> {/* Navigation Links */}
<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" /> <div className="flex flex-col gap-6 mb-12">
</svg> {(() => {
</a> const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
<a { page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
href={OFFICIAL_LINKS.twitter} { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
target="_blank" { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
rel="noopener noreferrer" { page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
className="p-2 rounded-lg text-nofx-text-muted bg-white/5 hover:text-[#1DA1F2]" { page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
> { page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> { page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
<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" /> { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
</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>
{/* Language Toggle */} const handleMobileNavClick = (tab: typeof navTabs[0]) => {
<div className="py-2"> if (tab.requiresAuth && !isLoggedIn) {
<div className="flex items-center gap-2 mb-2"> onLoginRequired?.(tab.label)
<span className="text-xs text-nofx-text-muted"> setMobileMenuOpen(false)
{t('language', language)}: return
</span> }
</div> if (onPageChange) {
<div className="space-y-1"> onPageChange(tab.page)
<button }
onClick={() => { navigate(tab.path)
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()
setMobileMenuOpen(false) 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 */} return navTabs.map((tab, i) => (
{!isLoggedIn && <motion.button
currentPage !== 'login' && key={tab.page}
currentPage !== 'register' && ( initial={{ x: -20, opacity: 0 }}
<div className="space-y-2 mt-2"> animate={{ x: 0, opacity: 1 }}
<a transition={{ delay: 0.1 + i * 0.05 }}
href="/login" onClick={() => handleMobileNavClick(tab)}
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" className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
onClick={() => setMobileMenuOpen(false)} ${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
> >
{t('signIn', language)} {currentPage === tab.page && (
</a> <motion.div
{registrationEnabled && ( layoutId="active-indicator"
<a className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
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)} {tab.label}
> {tab.requiresAuth && !isLoggedIn && (
{t('signUp', language)} <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">
</a> 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>
)}
</div> {/* Bottom Actions */}
</motion.div> <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> </nav>
) )
} }

View File

@@ -10,13 +10,15 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
export function LoginPage() { export function LoginPage() {
const { language } = useLanguage() const { language } = useLanguage()
const { login, loginAdmin, verifyOTP } = useAuth() const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
const [step, setStep] = useState<'login' | 'otp'>('login') const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [otpCode, setOtpCode] = useState('') const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = 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 [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [adminPassword, setAdminPassword] = useState('') const [adminPassword, setAdminPassword] = useState('')
@@ -62,9 +64,25 @@ export function LoginPage() {
const result = await login(email, password) const result = await login(email, password)
if (result.success) { 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) 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 { } else {
// Dismiss the "login expired" toast on successful login (no OTP required) // Dismiss the "login expired" toast on successful login (no OTP required)
if (expiredToastId) { if (expiredToastId) {
@@ -72,9 +90,18 @@ export function LoginPage() {
} }
} }
} else { } else {
const msg = result.message || t('loginFailed', language) // Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
setError(msg) if (result.qrCodeURL) {
toast.error(msg) 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) setLoading(false)
@@ -85,7 +112,11 @@ export function LoginPage() {
setError('') setError('')
setLoading(true) 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) { if (!result.success) {
const msg = result.message || t('verificationFailed', language) const msg = result.message || t('verificationFailed', language)
@@ -96,12 +127,20 @@ export function LoginPage() {
if (expiredToastId) { if (expiredToastId) {
toast.dismiss(expiredToastId) toast.dismiss(expiredToastId)
} }
// Clear qrCodeURL after successful completion
setQrCodeURL('')
setOtpSecret('')
} }
// 成功的话AuthContext会自动处理登录状态 // 成功的话AuthContext会自动处理登录状态
setLoading(false) setLoading(false)
} }
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
toast.success('Copied to clipboard')
}
return ( return (
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation> <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'} {loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
</button> </button>
</form> </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' ? ( ) : step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-5"> <form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -303,6 +303,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
{displayQty.toFixed(4)} {displayQty.toFixed(4)}
</td> </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 */} {/* P&L */}
<td className="py-3 px-4 text-right"> <td className="py-3 px-4 text-right">
<div className="font-mono font-semibold" style={{ color: pnlColor }}> <div className="font-mono font-semibold" style={{ color: pnlColor }}>
@@ -764,6 +769,12 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
> >
{t('positionHistory.qty', language)} {t('positionHistory.qty', language)}
</th> </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 <th
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider" className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
style={{ color: '#848E9C' }} style={{ color: '#848E9C' }}

View File

@@ -348,7 +348,7 @@ export function RegisterPage() {
{qrCodeURL ? ( {qrCodeURL ? (
<div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]"> <div className="bg-white p-2 rounded inline-block shadow-[0_0_30px_rgba(255,255,255,0.1)]">
<img <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" alt="QR Code"
className="w-32 h-32" className="w-32 h-32"
/> />
@@ -370,18 +370,42 @@ export function RegisterPage() {
</div> </div>
</div> </div>
<div className="space-y-3 font-mono text-xs text-zinc-400"> <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"> <div className="flex gap-3 items-start">
<span className="text-nofx-gold mt-0.5">01</span> <span className="text-nofx-gold font-bold mt-0.5">01</span>
<p>Install Google Authenticator or Authy on your mobile device.</p> <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>
<div className="flex gap-3">
<span className="text-nofx-gold mt-0.5">02</span> <div className="w-full h-px bg-zinc-800/50"></div>
<p>Scan the QR code above or manually enter the secret key.</p>
<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>
<div className="flex gap-3">
<span className="text-nofx-gold mt-0.5">03</span> <div className="w-full h-px bg-zinc-800/50"></div>
<p>Proceed to verify the generated 6-digit token.</p>
<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>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react' import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
import { useAuth } from '../../../contexts/AuthContext'
const agents = [ const agents = [
{ {
@@ -31,10 +32,10 @@ const agents = [
{ {
name: "GAMMA-RAY", name: "GAMMA-RAY",
class: "ARBITRAGE", class: "ARBITRAGE",
desc: "Risk-free spatial price equalization.", desc: "Low-risk spatial price equalization.",
apy: "24%", apy: "24%",
winRate: "99%", winRate: "99%",
risk: "ZERO", risk: "LOW",
color: "text-purple-400", color: "text-purple-400",
border: "border-purple-400/30", border: "border-purple-400/30",
bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]", bg_glow: "shadow-[0_0_30px_rgba(192,132,252,0.1)]",
@@ -43,7 +44,15 @@ const agents = [
] ]
export default function AgentGrid() { 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 ( return (
<section id="market-scanner" className="py-16 md:py-24 bg-nofx-bg relative overflow-hidden"> <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> </div>
{/* Action Btn */} {/* 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> <span className={agent.color}>[</span> INITIALIZE <span className={agent.color}>]</span>
</button> </button>
</div> </div>

View File

@@ -174,7 +174,7 @@ export default function TerminalHero() {
{/* Main Title - Massive & Impactful */} {/* Main Title - Massive & Impactful */}
{/* Main Title - Massive & Impactful */} {/* Main Title - Massive & Impactful */}
<div className="relative z-20 mix-blend-hard-light md:mix-blend-normal"> <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 /> 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> <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> </h1>
@@ -344,14 +344,14 @@ function CommunityStats() {
const stats = [ const stats = [
{ {
label: 'GITHUB STARS', label: 'GITHUB STARS',
value: isLoading ? '...' : (error ? '9.5k+' : stars.toLocaleString()), value: isLoading ? '...' : (error ? '9,700+' : stars.toLocaleString()),
icon: Star, icon: Star,
color: 'text-yellow-400', color: 'text-yellow-400',
href: OFFICIAL_LINKS.github href: OFFICIAL_LINKS.github
}, },
{ {
label: 'FORKS', label: 'FORKS',
value: isLoading ? '...' : (error ? '2.5k+' : forks.toLocaleString()), value: isLoading ? '...' : (error ? '2,600+' : forks.toLocaleString()),
icon: GitFork, icon: GitFork,
color: 'text-blue-400', color: 'text-blue-400',
href: `${OFFICIAL_LINKS.github}/fork` href: `${OFFICIAL_LINKS.github}/fork`
@@ -365,7 +365,7 @@ function CommunityStats() {
}, },
{ {
label: 'DEV COMMUNITY', label: 'DEV COMMUNITY',
value: '5,800+', // Hardcoded as per user request value: '6,000+', // Updated as per user request
icon: MessageCircle, icon: MessageCircle,
color: 'text-blue-500', color: 'text-blue-500',
href: OFFICIAL_LINKS.telegram href: OFFICIAL_LINKS.telegram

View File

@@ -18,6 +18,10 @@ interface AuthContextType {
message?: string message?: string
userID?: string userID?: string
requiresOTP?: boolean requiresOTP?: boolean
requiresOTPSetup?: boolean
qrCodeURL?: string
otpSecret?: string
email?: string
}> }>
loginAdmin: (password: string) => Promise<{ loginAdmin: (password: string) => Promise<{
success: boolean success: boolean
@@ -119,22 +123,43 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await response.json() const data = await response.json()
if (response.ok) { 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) { if (data.requires_otp) {
return { return {
success: true, success: true,
userID: data.user_id, userID: data.user_id,
requiresOTP: true, requiresOTP: true,
message: data.message, message: data.message,
qrCodeURL: data.qr_code_url,
otpSecret: data.otp_secret
} }
} }
// Unexpected success response
return { success: false, message: '登录响应异常' }
} else { } 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) { } catch (error) {
return { success: false, message: '登录失败,请重试' } return { success: false, message: '登录失败,请重试' }
} }
return { success: false, message: '未知错误' }
} }
const loginAdmin = async (password: string) => { const loginAdmin = async (password: string) => {

View File

@@ -1134,6 +1134,7 @@ export const translations = {
entry: 'Entry', entry: 'Entry',
exit: 'Exit', exit: 'Exit',
qty: 'Qty', qty: 'Qty',
value: 'Value',
lev: 'Lev', lev: 'Lev',
pnl: 'P&L', pnl: 'P&L',
duration: 'Duration', duration: 'Duration',
@@ -2280,6 +2281,7 @@ export const translations = {
entry: '开仓价', entry: '开仓价',
exit: '平仓价', exit: '平仓价',
qty: '数量', qty: '数量',
value: '仓位价值',
lev: '杠杆', lev: '杠杆',
pnl: '盈亏', pnl: '盈亏',
duration: '持仓时长', duration: '持仓时长',

View File

@@ -471,7 +471,13 @@ textarea::placeholder {
border-radius: 8px; border-radius: 8px;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-family: 'IBM Plex Mono', monospace; 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); color: var(--text-primary);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@@ -788,8 +794,20 @@ tr:hover {
color: var(--binance-red); color: var(--binance-red);
} }
.number-neutral { /* Scrollbar Hiding for sleek horizontal scrolls */
color: var(--text-secondary); .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 */ /* Divider */

View File

@@ -774,8 +774,19 @@ export function StrategyStudioPage() {
disabled={selectedStrategy.is_default} disabled={selectedStrategy.is_default}
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted" 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 && ( {hasChanges && (
<span className="text-xs text-nofx-gold"> </span> <span className="text-xs text-nofx-gold"> {language === 'zh' ? '未保存' : 'Unsaved'}</span>
)} )}
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">

View File

@@ -1,5 +1,4 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { mutate } from 'swr' import { mutate } from 'swr'
import { api } from '../lib/api' import { api } from '../lib/api'
import { ChartTabs } from '../components/ChartTabs' 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 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>
<div className="flex flex-col"> <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} {selectedTrader.trader_name}
</span> </span>
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2"> <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> </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="flex items-center gap-2">
<span className="opacity-60">Exchange:</span> <span className="opacity-60">Exchange:</span>
<span className="text-nofx-text-main font-semibold"> <span className="text-nofx-text-main font-semibold">
@@ -469,7 +468,7 @@ export function TraderDashboardPage({
)} )}
</span> </span>
</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="flex items-center gap-2">
<span className="opacity-60">Strategy:</span> <span className="opacity-60">Strategy:</span>
<span className="text-nofx-gold font-semibold tracking-wide"> <span className="text-nofx-gold font-semibold tracking-wide">
@@ -477,12 +476,12 @@ export function TraderDashboardPage({
</span> </span>
</span> </span>
{status && ( {status && (
<> <div className="hidden md:contents">
<span className="w-px h-3 bg-white/10" /> <span className="w-px h-3 bg-white/10" />
<span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span> <span>Cycles: <span className="text-nofx-text-main">{status.call_count}</span></span>
<span className="w-px h-3 bg-white/10" /> <span className="w-px h-3 bg-white/10" />
<span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span> <span>Runtime: <span className="text-nofx-text-main">{status.runtime_minutes} min</span></span>
</> </div>
)} )}
</div> </div>
</div> </div>
@@ -500,7 +499,7 @@ export function TraderDashboardPage({
)} )}
{/* Account Overview */} {/* 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 <StatCard
title={t('totalEquity', language)} title={t('totalEquity', language)}
value={`${account?.total_equity?.toFixed(2) || '0.00'}`} 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-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">{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-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 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" 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('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('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-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" title={t('leverage', language)}>{language === 'zh' ? '杠杆' : 'Lev.'}</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('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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -635,11 +634,11 @@ export function TraderDashboardPage({
{language === 'zh' ? '平仓' : 'Close'} {language === 'zh' ? '平仓' : 'Close'}
</button> </button>
</td> </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 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">{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.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 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 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">{pos.leverage}x</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"> <td className="px-1 py-3 font-mono whitespace-nowrap text-right">
<span <span
className={`font-bold ${pos.unrealized_pnl >= 0 ? 'text-nofx-green shadow-nofx-green' : 'text-nofx-red shadow-nofx-red'}`} 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)} {pos.unrealized_pnl.toFixed(2)}
</span> </span>
</td> </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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -21,6 +21,7 @@ export default {
'nofx-accent': '#00F0FF', // Cyan Cyber 'nofx-accent': '#00F0FF', // Cyan Cyber
'nofx-text': { 'nofx-text': {
DEFAULT: '#EAECEF', DEFAULT: '#EAECEF',
main: '#EAECEF',
muted: '#848E9C', muted: '#848E9C',
}, },
'nofx-success': '#0ECB81', 'nofx-success': '#0ECB81',