Merge branch 'dev' into feat/auto-balance-sync

This commit is contained in:
Icyoung
2025-11-05 16:01:57 +08:00
committed by GitHub
90 changed files with 13151 additions and 3988 deletions
+43 -227
View File
@@ -1,33 +1,44 @@
# Pull Request | PR 提交
> **💡 提示 Tip:** 推荐 PR 标题格式 Recommended PR title format: `type(scope): description`
> 例如 Examples: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
> 详情 Details: [PR Title Guide](./PR_TITLE_GUIDE.md)
> **📋 选择专用模板 | Choose Specialized Template**
>
> 我们现在提供了针对不同类型PR的专用模板,帮助你更快速地填写PR信息:
> We now offer specialized templates for different types of PRs to help you fill out the information faster:
>
> - 🔧 **[Backend PR Template](./PULL_REQUEST_TEMPLATE/backend.md)** | 后端PR模板 - For Go/API/Trading changes
> - 🎨 **[Frontend PR Template](./PULL_REQUEST_TEMPLATE/frontend.md)** | 前端PR模板 - For UI/UX changes
> - 📝 **[Documentation PR Template](./PULL_REQUEST_TEMPLATE/docs.md)** | 文档PR模板 - For documentation updates
> - 📦 **[General PR Template](./PULL_REQUEST_TEMPLATE/general.md)** | 通用PR模板 - For mixed or other changes
>
> **如何使用?| How to use?**
> - 创建PR时,在URL中添加 `?template=backend.md` 或其他模板名称
> - When creating a PR, add `?template=backend.md` or other template name to the URL
> - 或者直接复制粘贴对应模板的内容
> - Or simply copy and paste the content from the corresponding template
---
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
---
## 📝 Description | 描述
<!-- Provide a brief summary of your changes -->
<!-- 简要描述你的变更 -->
**English:** **中文:**
**English:**
**中文:**
---
## 🎯 Type of Change | 变更类型
<!-- Mark the relevant option with an "x" -->
<!-- 在相关选项上打"x" -->
- [ ] 🐛 Bug fix | 修复 Bug(不影响现有功能的修复)
- [ ] ✨ New feature | 新功能(不影响现有功能的新增)
- [ ] 💥 Breaking change | 破坏性变更(会导致现有功能无法正常工作的修复或功能)
- [ ] 🐛 Bug fix | 修复 Bug
- [ ] ✨ New feature | 新功能
- [ ] 💥 Breaking change | 破坏性变更
- [ ] 📝 Documentation update | 文档更新
- [ ] 🎨 Code style update | 代码样式更新(格式化、重命名等)
- [ ] ♻️ Refactoring | 重构(无功能变更)
- [ ] 🎨 Code style update | 代码样式更新
- [ ] ♻️ Refactoring | 重构
- [ ] ⚡ Performance improvement | 性能优化
- [ ] ✅ Test update | 测试更新
- [ ] 🔧 Build/config change | 构建/配置变更
@@ -37,9 +48,6 @@
## 🔗 Related Issues | 相关 Issue
<!-- Link related issues below. Use "Closes #123" to auto-close issues when PR is merged -->
<!-- 在下方关联相关 issue。使用 "Closes #123" 可以在 PR 合并时自动关闭 issue -->
- Closes # | 关闭 #
- Related to # | 相关 #
@@ -47,242 +55,50 @@
## 📋 Changes Made | 具体变更
<!-- List the specific changes you made -->
<!-- 列出你做的具体变更 -->
**English:**
- Change 1
- Change 2
- Change 3
**中文:**
- 变更 1
- 变更 2
- 变更 3
**English:** **中文:**
-
-
---
## 🧪 Testing | 测试
### Manual Testing | 手动测试
<!-- Describe how you tested your changes -->
<!-- 描述你如何测试你的变更 -->
- [ ] Tested locally | 本地测试通过
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
- [ ] Tested with different configurations | 测试了不同配置
- [ ] Tests pass | 测试通过
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
### Test Environment | 测试环境
- **OS | 操作系统:** [e.g. macOS, Ubuntu, Windows]
- **Go Version | Go 版本:** [e.g. 1.21.5]
- **Node Version | Node 版本:** [e.g. 18.x] (if applicable | 如适用)
- **Exchange | 交易所:** [if applicable | 如适用]
### Test Results | 测试结果
<!-- Paste relevant test output or describe results -->
<!-- 粘贴相关测试输出或描述结果 -->
```
Test output here | 测试输出
```
---
## 📸 Screenshots / Demo | 截图/演示
<!-- If applicable, add screenshots or video demo -->
<!-- 如适用,添加截图或视频演示 -->
<!-- For UI changes, include before/after screenshots -->
<!-- 对于 UI 变更,包含变更前后的截图 -->
**Before | 变更前:**
**After | 变更后:**
---
## ✅ Checklist | 检查清单
<!-- Mark completed items with an "x" -->
<!-- 在已完成的项目上打"x" -->
### Code Quality | 代码质量
- [ ] My code follows the project's code style | 我的代码遵循项目代码风格 ([Contributing Guide](../CONTRIBUTING.md))
- [ ] I have performed a self-review of my code | 我已进行代码自查
- [ ] I have commented my code, particularly in hard-to-understand areas | 我已添加代码注释,特别是难以理解的部分
- [ ] My changes generate no new warnings or errors | 我的变更没有产生新的警告或错误
- [ ] Code compiles successfully | 代码编译成功 (`go build` / `npm run build`)
- [ ] I have run `go fmt` (for Go code) | 我已运行 `go fmt`Go 代码)
- [ ] I have run `npm run lint` (for frontend code) | 我已运行 `npm run lint`(前端代码)
### Testing | 测试
- [ ] I have added tests that prove my fix is effective or that my feature works | 我已添加证明修复有效或功能正常的测试
- [ ] New and existing unit tests pass locally | 新旧单元测试在本地通过
- [ ] I have tested on testnet (for trading/exchange changes) | 我已在测试网测试(交易/交易所变更)
- [ ] Integration tests pass | 集成测试通过
- [ ] Code follows project style | 代码遵循项目风格
- [ ] Self-review completed | 已完成代码自查
- [ ] Comments added for complex logic | 已添加必要注释
### Documentation | 文档
- [ ] I have updated the documentation accordingly | 我已相应更新文档
- [ ] I have updated the README if needed | 我已更新 README(如需要)
- [ ] I have added inline code comments where necessary | 我已在必要处添加代码注释
- [ ] I have updated type definitions (for TypeScript changes) | 我已更新类型定义(TypeScript 变更)
- [ ] I have updated API documentation (if applicable) | 我已更新 API 文档(如适用)
- [ ] Updated relevant documentation | 已更新相关文档
### Git
- [ ] My commits follow the conventional commits format | 我的提交遵循 Conventional Commits 格式 (`feat:`, `fix:`, etc.)
- [ ] I have rebased my branch on the latest `dev` branch | 我已将分支 rebase 到最新的 `dev` 分支
- [ ] There are no merge conflicts | 没有合并冲突
- [ ] Commit messages are clear and descriptive | 提交信息清晰明确
---
## 🔒 Security Considerations | 安全考虑
<!-- Answer these questions for security-sensitive changes -->
<!-- 对于安全敏感的变更,请回答以下问题 -->
- [ ] No API keys or secrets are hardcoded | 没有硬编码 API 密钥或密钥
- [ ] User inputs are properly validated | 用户输入已正确验证
- [ ] No SQL injection vulnerabilities introduced | 未引入 SQL 注入漏洞
- [ ] No XSS vulnerabilities introduced | 未引入 XSS 漏洞
- [ ] Authentication/authorization properly handled | 认证/授权已正确处理
- [ ] Sensitive data is encrypted | 敏感数据已加密
- [ ] N/A (not security-related) | 不适用(非安全相关)
---
## ⚡ Performance Impact | 性能影响
<!-- Describe any performance implications -->
<!-- 描述任何性能影响 -->
- [ ] No significant performance impact | 无显著性能影响
- [ ] Performance improved | 性能提升
- [ ] Performance may be impacted (explain below) | 性能可能受影响(请在下方说明)
<!-- If performance impacted, explain: -->
<!-- 如果性能受影响,请说明: -->
**English:**
**中文:**
---
## 🌐 Internationalization | 国际化
<!-- For UI/documentation changes -->
<!-- 对于 UI/文档变更 -->
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
- [ ] Both English and Chinese versions provided | 提供了中英文版本
- [ ] N/A | 不适用
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
- [ ] No merge conflicts | 无合并冲突
---
## 📚 Additional Notes | 补充说明
<!-- Any additional information for reviewers -->
<!-- 给审查者的任何补充信息 -->
**English:** **中文:**
**English:**
**中文:**
---
## 💰 For Bounty Claims | 赏金申请
**By submitting this PR, I confirm | 提交此 PR,我确认:**
<!-- Fill this section only if claiming a bounty -->
<!-- 仅在申请赏金时填写此部分 -->
- [ ] This PR is for bounty issue # | 此 PR 用于赏金 issue #
- [ ] All acceptance criteria from the bounty issue are met | 满足赏金 issue 的所有验收标准
- [ ] I have included a demo video/screenshots | 我已包含演示视频/截图
- [ ] I am ready for payment upon merge | 我准备好在合并后接收付款
**Payment Details | 付款详情:** <!-- Discuss privately with maintainers | 与维护者私下讨论 -->
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 已阅读贡献指南
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 同意行为准则
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
---
## 🙏 Reviewer Notes | 审查者注意事项
<!-- Optional: anything specific you want reviewers to focus on? -->
<!-- 可选:你希望审查者关注的特定内容? -->
**English:**
**中文:**
---
## 📋 PR Size Estimate | PR 大小估计
<!-- This helps reviewers plan their time -->
<!-- 这有助于审查者安排时间 -->
- [ ] 🟢 Small (< 100 lines) | 小(< 100 行)
- [ ] 🟡 Medium (100-500 lines) | 中(100-500 行)
- [ ] 🔴 Large (> 500 lines) | 大(> 500 行)
<!-- For large PRs, consider: -->
<!-- 对于大型 PR,考虑: -->
<!-- - Breaking into smaller, focused PRs | 拆分为更小、更专注的 PR -->
<!-- - Providing a detailed explanation | 提供详细说明 -->
<!-- - Highlighting the most important changes | 突出最重要的变更 -->
---
## 🎯 Review Focus Areas | 审查重点
<!-- Help reviewers know where to focus their attention -->
<!-- 帮助审查者了解重点关注的地方 -->
Please pay special attention to:
请特别注意:
- [ ] Logic changes | 逻辑变更
- [ ] Security implications | 安全影响
- [ ] Performance optimization | 性能优化
- [ ] API changes | API 变更
- [ ] Database schema changes | 数据库架构变更
- [ ] UI/UX changes | UI/UX 变更
---
**By submitting this PR, I confirm that:**
**提交此 PR,我确认:**
- [ ] I have read the [Contributing Guidelines](../CONTRIBUTING.md) | 我已阅读[贡献指南](../CONTRIBUTING.md)
- [ ] I agree to the [Code of Conduct](../CODE_OF_CONDUCT.md) | 我同意[行为准则](../CODE_OF_CONDUCT.md)
- [ ] My contribution is licensed under the AGPL-3.0 License | 我的贡献遵循 AGPL-3.0 许可证
- [ ] I understand this is a voluntary contribution | 我理解这是自愿贡献
- [ ] I have the right to submit this code | 我有权提交此代码
---
<!--
🌟 感谢你的贡献!Thank you for your contribution!
贡献者来自世界各地,我们重视每一份贡献。
Contributors come from all around the world, and we value every contribution.
如果你是首次贡献,欢迎加入我们的社区!
If this is your first contribution, welcome to our community!
💬 需要帮助?Feel free to ask questions in:
- GitHub Discussions
- Discord: [链接 Link]
- Telegram: [链接 Link]
-->
🌟 **Thank you for your contribution! | 感谢你的贡献!**
+213
View File
@@ -0,0 +1,213 @@
# PR Templates | PR 模板
## 📋 模板概述 | Template Overview
我们提供了4种针对不同类型PR的专用模板,帮助贡献者快速填写PR信息:
We offer 4 specialized templates for different types of PRs to help contributors quickly fill out PR information:
### 1. 🔧 Backend Template | 后端模板
**文件:** `backend.md`
**适用于 | Use for:**
- Go代码变更 | Go code changes
- API端点开发 | API endpoint development
- 交易逻辑实现 | Trading logic implementation
- 后端性能优化 | Backend performance optimization
- 数据库相关改动 | Database-related changes
**包含 | Includes:**
- Go测试环境配置 | Go test environment
- 安全考虑检查 | Security considerations
- 性能影响评估 | Performance impact assessment
- `go fmt``go build` 检查 | `go fmt` and `go build` checks
### 2. 🎨 Frontend Template | 前端模板
**文件:** `frontend.md`
**适用于 | Use for:**
- UI/UX变更 | UI/UX changes
- React/Vue组件开发 | React/Vue component development
- 前端样式更新 | Frontend styling updates
- 浏览器兼容性修复 | Browser compatibility fixes
- 前端性能优化 | Frontend performance optimization
**包含 | Includes:**
- 截图/演示要求 | Screenshots/demo requirements
- 浏览器测试清单 | Browser testing checklist
- 国际化检查 | Internationalization checks
- 响应式设计验证 | Responsive design verification
- `npm run lint``npm run build` 检查 | Linting and build checks
### 3. 📝 Documentation Template | 文档模板
**文件:** `docs.md`
**适用于 | Use for:**
- README更新 | README updates
- API文档编写 | API documentation
- 教程和指南 | Tutorials and guides
- 代码注释改进 | Code comment improvements
- 翻译工作 | Translation work
**包含 | Includes:**
- 文档类型分类 | Documentation type classification
- 内容质量检查 | Content quality checks
- 双语要求(中英文)| Bilingual requirements (EN/CN)
- 链接有效性验证 | Link validity verification
### 4. 📦 General Template | 通用模板
**文件:** `general.md`
**适用于 | Use for:**
- 混合类型变更 | Mixed-type changes
- 跨多个领域的PR | Cross-domain PRs
- 构建配置变更 | Build configuration changes
- 依赖更新 | Dependency updates
- 不确定使用哪个模板时 | When unsure which template to use
## 🤖 自动模板建议 | Automatic Template Suggestion
我们的GitHub Action会自动分析你的PR并建议最合适的模板:
Our GitHub Action automatically analyzes your PR and suggests the most suitable template:
### 工作原理 | How it works:
1. **文件分析 | File Analysis**
- 检测PR中所有变更的文件类型
- Detects all changed file types in the PR
2. **智能判断 | Smart Detection**
- 如果 >50% 是 `.go` 文件 → 建议**后端模板**
- If >50% are `.go` files → Suggests **Backend template**
- 如果 >50% 是 `.js/.ts/.tsx/.vue` 文件 → 建议**前端模板**
- If >50% are `.js/.ts/.tsx/.vue` files → Suggests **Frontend template**
- 如果 >70% 是 `.md` 文件 → 建议**文档模板**
- If >70% are `.md` files → Suggests **Documentation template**
3. **自动评论 | Auto-comment**
- 如果检测到你使用了默认模板,但应该用专用模板
- If it detects you're using the default template but should use a specialized one
- 会自动添加友好的评论建议
- It will automatically add a friendly comment suggestion
4. **自动标签 | Auto-labeling**
- 自动添加对应的标签:`backend``frontend``documentation`
- Automatically adds corresponding labels: `backend`, `frontend`, `documentation`
## 📖 使用方法 | How to Use
### 方法1: URL参数(推荐) | Method 1: URL Parameter (Recommended)
创建PR时,在URL末尾添加模板参数:
When creating a PR, add the template parameter to the URL:
```
https://github.com/YOUR_ORG/nofx/compare/dev...YOUR_BRANCH?template=backend.md
```
替换 `backend.md` 为:
Replace `backend.md` with:
- `backend.md` - 后端模板 | Backend template
- `frontend.md` - 前端模板 | Frontend template
- `docs.md` - 文档模板 | Documentation template
- `general.md` - 通用模板 | General template
### 方法2: 手动选择 | Method 2: Manual Selection
1. 创建PR时,默认模板会显示
When creating a PR, the default template will be shown
2. 根据顶部的指引链接,点击查看对应的模板
Follow the guidance links at the top to view the corresponding template
3. 复制模板内容到PR描述中
Copy the template content into the PR description
### 方法3: 跟随自动建议 | Method 3: Follow Auto-suggestion
1. 使用任何模板创建PR
Create a PR with any template
2. GitHub Action会自动分析并评论建议
GitHub Action will automatically analyze and comment with a suggestion
3. 根据建议更新PR描述
Update the PR description based on the suggestion
## 🎯 最佳实践 | Best Practices
1. **提前选择 | Choose in Advance**
- 在创建PR前确定变更类型
- Determine the change type before creating the PR
2. **完整填写 | Complete Filling**
- 不要跳过必填项(标记为 required)
- Don't skip required items
3. **保持简洁 | Keep it Concise**
- 描述清晰但简洁
- Keep descriptions clear but concise
4. **添加截图 | Add Screenshots**
- 对于UI变更,务必添加截图
- For UI changes, always add screenshots
5. **测试证明 | Test Evidence**
- 提供测试通过的证据
- Provide evidence that tests pass
## 🔧 自定义 | Customization
如果需要修改模板或自动检测逻辑:
If you need to modify templates or auto-detection logic:
1. **修改模板** | **Modify Templates**
- 编辑 `.github/PULL_REQUEST_TEMPLATE/*.md` 文件
- Edit `.github/PULL_REQUEST_TEMPLATE/*.md` files
2. **调整检测阈值** | **Adjust Detection Threshold**
- 编辑 `.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)
3. **添加新模板** | **Add New Template**
-`PULL_REQUEST_TEMPLATE/` 目录创建新的 `.md` 文件
- Create a new `.md` file in the `PULL_REQUEST_TEMPLATE/` directory
- 更新工作流以支持新的文件类型检测
- Update the workflow to support new file type detection
## ❓ FAQ
**Q: 我的PR既有前端又有后端代码,用哪个模板?**
**Q: My PR has both frontend and backend code, which template should I use?**
A: 使用**通用模板**`general.md`),或选择主要变更类型的模板。
A: Use the **General template** (`general.md`), or choose the template for the primary change type.
---
**Q: 自动建议的模板不合适怎么办?**
**Q: What if the automatically suggested template is not suitable?**
A: 你可以忽略建议,继续使用当前模板。自动建议仅供参考。
A: You can ignore the suggestion and continue using the current template. Auto-suggestions are for reference only.
---
**Q: 可以不使用任何模板吗?**
**Q: Can I not use any template?**
A: 不推荐。模板帮助确保PR包含必要信息,加快审查速度。
A: Not recommended. Templates help ensure PRs contain necessary information and speed up reviews.
---
**Q: 如何禁用自动模板建议?**
**Q: How to disable automatic template suggestions?**
A: 删除或禁用 `.github/workflows/pr-template-suggester.yml` 文件。
A: Delete or disable the `.github/workflows/pr-template-suggester.yml` file.
---
🌟 **感谢使用我们的PR模板系统!| Thank you for using our PR template system!**
+121
View File
@@ -0,0 +1,121 @@
# Pull Request - Backend | 后端 PR
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue`
---
## 📝 Description | 描述
**English:** **中文:**
---
## 🎯 Type of Change | 变更类型
- [ ] 🐛 Bug fix | 修复 Bug
- [ ] ✨ New feature | 新功能
- [ ] 💥 Breaking change | 破坏性变更
- [ ] ♻️ Refactoring | 重构
- [ ] ⚡ Performance improvement | 性能优化
- [ ] 🔒 Security fix | 安全修复
- [ ] 🔧 Build/config change | 构建/配置变更
---
## 🔗 Related Issues | 相关 Issue
- Closes # | 关闭 #
- Related to # | 相关 #
---
## 📋 Changes Made | 具体变更
**English:** **中文:**
-
-
---
## 🧪 Testing | 测试
### Test Environment | 测试环境
- **OS | 操作系统:**
- **Go Version | Go 版本:**
- **Exchange | 交易所:** [if applicable | 如适用]
### Manual Testing | 手动测试
- [ ] Tested locally | 本地测试通过
- [ ] Tested on testnet | 测试网测试通过(交易所集成相关)
- [ ] Unit tests pass | 单元测试通过
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
### Test Results | 测试结果
```
Test output here | 测试输出
```
---
## 🔒 Security Considerations | 安全考虑
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
- [ ] User inputs properly validated | 用户输入已正确验证
- [ ] No SQL injection vulnerabilities | 无 SQL 注入漏洞
- [ ] Authentication/authorization properly handled | 认证/授权正确处理
- [ ] Sensitive data is encrypted | 敏感数据已加密
- [ ] N/A (not security-related) | 不适用
---
## ⚡ Performance Impact | 性能影响
- [ ] No significant performance impact | 无显著性能影响
- [ ] Performance improved | 性能提升
- [ ] Performance may be impacted (explain below) | 性能可能受影响
**If impacted, explain | 如果受影响,请说明:**
---
## ✅ Checklist | 检查清单
### Code Quality | 代码质量
- [ ] Code follows project style | 代码遵循项目风格
- [ ] Self-review completed | 已完成代码自查
- [ ] Comments added for complex logic | 已添加必要注释
- [ ] Code compiles successfully | 代码编译成功 (`go build`)
- [ ] Ran `go fmt` | 已运行 `go fmt`
### Documentation | 文档
- [ ] Updated relevant documentation | 已更新相关文档
- [ ] Added inline comments where necessary | 已添加必要的代码注释
- [ ] Updated API documentation (if applicable) | 已更新 API 文档
### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
- [ ] No merge conflicts | 无合并冲突
---
## 📚 Additional Notes | 补充说明
**English:** **中文:**
---
**By submitting this PR, I confirm | 提交此 PR,我确认:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
---
🌟 **Thank you for your contribution! | 感谢你的贡献!**
+97
View File
@@ -0,0 +1,97 @@
# Pull Request - Documentation | 文档 PR
> **💡 提示 Tip:** 推荐 PR 标题格式 `docs(scope): description`
> 例如: `docs(api): update trading endpoints` | `docs(readme): add setup guide`
---
## 📝 Description | 描述
**English:** **中文:**
---
## 📚 Type of Documentation | 文档类型
- [ ] 📖 README update | README 更新
- [ ] 📋 API documentation | API 文档
- [ ] 🎓 Tutorial/Guide | 教程/指南
- [ ] 📝 Code comments | 代码注释
- [ ] 🔧 Configuration docs | 配置文档
- [ ] 🐛 Fix typo/error | 修复拼写/错误
- [ ] 🌍 Translation | 翻译
---
## 🔗 Related Issues | 相关 Issue
- Closes # | 关闭 #
- Related to # | 相关 #
---
## 📋 Changes Made | 具体变更
**English:** **中文:**
-
-
---
## 📸 Screenshots (if applicable) | 截图(如适用)
<!-- For documentation with images, diagrams, or UI examples -->
<!-- 用于包含图片、图表或 UI 示例的文档 -->
---
## 🌐 Internationalization | 国际化
- [ ] English version complete | 英文版本完整
- [ ] Chinese version complete | 中文版本完整
- [ ] Both versions are consistent | 两个版本内容一致
- [ ] N/A (only one language needed) | 不适用(只需要一种语言)
---
## ✅ Checklist | 检查清单
### Content Quality | 内容质量
- [ ] Information is accurate and up-to-date | 信息准确且最新
- [ ] Language is clear and concise | 语言清晰简洁
- [ ] No spelling or grammar errors | 无拼写或语法错误
- [ ] Links are valid and working | 链接有效且可用
- [ ] Code examples are tested and working | 代码示例已测试且可用
- [ ] Formatting is consistent | 格式一致
### Documentation Standards | 文档标准
- [ ] Follows project documentation style | 遵循项目文档风格
- [ ] Includes necessary examples | 包含必要的示例
- [ ] Technical terms are explained | 技术术语已解释
- [ ] Self-review completed | 已完成自查
### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
- [ ] No merge conflicts | 无合并冲突
---
## 📚 Additional Notes | 补充说明
**English:** **中文:**
---
**By submitting this PR, I confirm | 提交此 PR,我确认:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
---
🌟 **Thank you for your contribution! | 感谢你的贡献!**
+119
View File
@@ -0,0 +1,119 @@
# Pull Request - Frontend | 前端 PR
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
> 例如: `feat(ui): add dark mode toggle` | `fix(form): resolve validation bug`
---
## 📝 Description | 描述
**English:** **中文:**
---
## 🎯 Type of Change | 变更类型
- [ ] 🐛 Bug fix | 修复 Bug
- [ ] ✨ New feature | 新功能
- [ ] 💥 Breaking change | 破坏性变更
- [ ] 🎨 Code style update | 代码样式更新
- [ ] ♻️ Refactoring | 重构
- [ ] ⚡ Performance improvement | 性能优化
---
## 🔗 Related Issues | 相关 Issue
- Closes # | 关闭 #
- Related to # | 相关 #
---
## 📋 Changes Made | 具体变更
**English:** **中文:**
-
-
---
## 📸 Screenshots / Demo | 截图/演示
<!-- For UI changes, include before/after screenshots or video demo -->
<!-- 对于 UI 变更,请包含变更前后的截图或视频演示 -->
**Before | 变更前:**
**After | 变更后:**
---
## 🧪 Testing | 测试
### Test Environment | 测试环境
- **OS | 操作系统:**
- **Node Version | Node 版本:**
- **Browser(s) | 浏览器:**
### Manual Testing | 手动测试
- [ ] Tested in development mode | 开发模式测试通过
- [ ] Tested production build | 生产构建测试通过
- [ ] Tested on multiple browsers | 多浏览器测试通过
- [ ] Tested responsive design | 响应式设计测试通过
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
---
## 🌐 Internationalization | 国际化
- [ ] All user-facing text supports i18n | 所有面向用户的文本支持国际化
- [ ] Both English and Chinese versions provided | 提供了中英文版本
- [ ] N/A | 不适用
---
## ✅ Checklist | 检查清单
### Code Quality | 代码质量
- [ ] Code follows project style | 代码遵循项目风格
- [ ] Self-review completed | 已完成代码自查
- [ ] Comments added for complex logic | 已添加必要注释
- [ ] Code builds successfully | 代码构建成功 (`npm run build`)
- [ ] Ran `npm run lint` | 已运行 `npm run lint`
- [ ] No console errors or warnings | 无控制台错误或警告
### Testing | 测试
- [ ] Component tests added/updated | 已添加/更新组件测试
- [ ] Tests pass locally | 测试在本地通过
### Documentation | 文档
- [ ] Updated relevant documentation | 已更新相关文档
- [ ] Updated type definitions (TypeScript) | 已更新类型定义
- [ ] Added JSDoc comments where necessary | 已添加 JSDoc 注释
### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
- [ ] No merge conflicts | 无合并冲突
---
## 📚 Additional Notes | 补充说明
**English:** **中文:**
---
**By submitting this PR, I confirm | 提交此 PR,我确认:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
---
🌟 **Thank you for your contribution! | 感谢你的贡献!**
+98
View File
@@ -0,0 +1,98 @@
# Pull Request - General | 通用 PR
> **💡 提示 Tip:** 推荐 PR 标题格式 `type(scope): description`
> 例如: `feat(trader): add new strategy` | `fix(api): resolve auth issue` | `docs(readme): update`
---
## 📝 Description | 描述
**English:** **中文:**
---
## 🎯 Type of Change | 变更类型
- [ ] 🐛 Bug fix | 修复 Bug
- [ ] ✨ New feature | 新功能
- [ ] 💥 Breaking change | 破坏性变更
- [ ] 📝 Documentation update | 文档更新
- [ ] 🎨 Code style update | 代码样式更新
- [ ] ♻️ Refactoring | 重构
- [ ] ⚡ Performance improvement | 性能优化
- [ ] ✅ Test update | 测试更新
- [ ] 🔧 Build/config change | 构建/配置变更
- [ ] 🔒 Security fix | 安全修复
---
## 🔗 Related Issues | 相关 Issue
- Closes # | 关闭 #
- Related to # | 相关 #
---
## 📋 Changes Made | 具体变更
**English:** **中文:**
-
-
---
## 🧪 Testing | 测试
- [ ] Tested locally | 本地测试通过
- [ ] Tests pass | 测试通过
- [ ] Verified no existing functionality broke | 确认没有破坏现有功能
**Test details | 测试详情:**
---
## ✅ Checklist | 检查清单
### Code Quality | 代码质量
- [ ] Code follows project style | 代码遵循项目风格
- [ ] Self-review completed | 已完成代码自查
- [ ] Comments added for complex logic | 已添加必要注释
- [ ] No new warnings or errors | 无新的警告或错误
### Documentation | 文档
- [ ] Updated relevant documentation | 已更新相关文档
- [ ] Added inline comments where necessary | 已添加必要的代码注释
### Git
- [ ] Commits follow conventional format | 提交遵循 Conventional Commits 格式
- [ ] Rebased on latest `dev` branch | 已 rebase 到最新 `dev` 分支
- [ ] No merge conflicts | 无合并冲突
---
## 🔒 Security (if applicable) | 安全(如适用)
- [ ] No API keys or secrets hardcoded | 没有硬编码 API 密钥
- [ ] User inputs properly validated | 用户输入已正确验证
- [ ] N/A | 不适用
---
## 📚 Additional Notes | 补充说明
**English:** **中文:**
---
**By submitting this PR, I confirm | 提交此 PR,我确认:**
- [ ] I have read the [Contributing Guidelines](../../CONTRIBUTING.md) | 已阅读贡献指南
- [ ] I agree to the [Code of Conduct](../../CODE_OF_CONDUCT.md) | 同意行为准则
- [ ] My contribution is licensed under AGPL-3.0 | 贡献遵循 AGPL-3.0 许可证
---
🌟 **Thank you for your contribution! | 感谢你的贡献!**
+189
View File
@@ -0,0 +1,189 @@
name: PR Template Suggester
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
suggest-template:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Analyze PR files and auto-apply template
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
let goFiles = 0, jsFiles = 0, tsFiles = 0, mdFiles = 0, otherFiles = 0;
for (const file of files) {
const filename = file.filename.toLowerCase();
if (filename.endsWith('.go')) goFiles++;
else if (filename.endsWith('.js') || filename.endsWith('.jsx')) jsFiles++;
else if (filename.endsWith('.ts') || filename.endsWith('.tsx') || filename.endsWith('.vue')) tsFiles++;
else if (filename.endsWith('.md')) mdFiles++;
else otherFiles++;
}
const totalFiles = goFiles + jsFiles + tsFiles + mdFiles + otherFiles;
if (totalFiles === 0) { console.log('No files changed'); return; }
let suggestedTemplate = null, templateEmoji = '', templateLabel = '';
if (goFiles / totalFiles > 0.5) {
suggestedTemplate = 'backend'; templateEmoji = '🔧'; templateLabel = 'backend';
} else if ((jsFiles + tsFiles) / totalFiles > 0.5) {
suggestedTemplate = 'frontend'; templateEmoji = '🎨'; templateLabel = 'frontend';
} else if (mdFiles / totalFiles > 0.7) {
suggestedTemplate = 'docs'; templateEmoji = '📝'; templateLabel = 'documentation';
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
});
const prBody = pr.body || '';
const usesBackendTemplate = prBody.includes('Pull Request - Backend');
const usesFrontendTemplate = prBody.includes('Pull Request - Frontend');
const usesDocsTemplate = prBody.includes('Pull Request - Documentation');
const usesGeneralTemplate = prBody.includes('Pull Request - General');
const usingDefaultTemplate = !usesBackendTemplate && !usesFrontendTemplate && !usesDocsTemplate && !usesGeneralTemplate;
if (templateLabel) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [templateLabel]
});
console.log('Added label: ' + templateLabel);
} catch (error) {
console.log('Label might not exist, skipping...');
}
}
function isPRBodyEmpty(body) {
if (!body || body.trim().length < 100) return true;
const hasEmptyDescription = body.includes('**English:**') && body.match(/\*\*English:\*\*\s*\n\s*\n\s*\n/);
const hasEmptyChanges = body.includes('具体变更') && body.match(/\*\*中文:\*\*\s*\n\s*-\s*\n\s*-\s*\n/);
if (hasEmptyDescription || hasEmptyChanges) return true;
const descMatch = body.match(/\*\*English:\*\*[|]\s*\*\*中文:\*\*\s*\n\s*(.+)/);
if (!descMatch || descMatch[1].trim().length < 10) return true;
return false;
}
if (suggestedTemplate && usingDefaultTemplate) {
const shouldAutoApply = isPRBodyEmpty(prBody);
const templatePath = '.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
if (shouldAutoApply) {
try {
const { data: templateFile } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: templatePath,
ref: context.payload.pull_request.head.ref
});
const templateContent = Buffer.from(templateFile.content, 'base64').toString('utf-8');
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
body: templateContent
});
console.log('Auto-applied ' + suggestedTemplate + ' template');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const notifyComment = '## ' + templateEmoji + ' 已自动应用专用模板 | Auto-Applied Template\n\n' +
'检测到您的PR主要包含 **' + suggestedTemplate + '** 相关的变更,系统已自动为您应用相应的模板。\n\n' +
'Detected that your PR primarily contains **' + suggestedTemplate + '** changes. The appropriate template has been automatically applied.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**已应用模板 | Applied Template**\n`' + templatePath + '`\n\n' +
'✨ 您现在可以直接在PR描述中填写相关信息了!\n\n' +
'✨ You can now fill in the relevant information in the PR description!';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: notifyComment
});
} catch (error) {
console.log('Failed to fetch or apply template: ' + error.message);
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const fallbackComment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。\n\n' +
'**推荐模板 | Recommended Template:** `.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md`\n\n' +
'**如何使用 | How to use:** [点击查看模板内容](' + templateUrl + ')';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fallbackComment
});
}
} else {
console.log('PR body has content, sending suggestion only');
let fileStats = [];
if (goFiles > 0) fileStats.push('- 🔧 Go files: ' + goFiles);
if (jsFiles > 0) fileStats.push('- 🎨 JavaScript files: ' + jsFiles);
if (tsFiles > 0) fileStats.push('- 🎨 TypeScript files: ' + tsFiles);
if (mdFiles > 0) fileStats.push('- 📝 Markdown files: ' + mdFiles);
if (otherFiles > 0) fileStats.push('- 📦 Other files: ' + otherFiles);
const fileStatsText = fileStats.join('\n');
const templateUrl = 'https://raw.githubusercontent.com/' + context.repo.owner + '/' + context.repo.repo + '/dev/.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md';
const comment = '## ' + templateEmoji + ' 建议使用专用模板 | Suggested Template\n\n' +
'您的PR主要包含 **' + suggestedTemplate + '** 相关的变更。我们建议使用更适合的模板以简化填写。\n\n' +
'Your PR primarily contains **' + suggestedTemplate + '** changes. We suggest using a more suitable template to simplify filling.\n\n' +
'**文件统计 | File Statistics**\n' + fileStatsText + '\n\n' +
'**推荐模板 | Recommended Template**\n```\n.github/PULL_REQUEST_TEMPLATE/' + suggestedTemplate + '.md\n```\n\n' +
'**如何使用 | How to use**\n' +
'1. 编辑PR描述 | Edit PR description\n' +
'2. 复制 [' + suggestedTemplate + ' 模板内容](' + templateUrl + ') | Copy [' + suggestedTemplate + ' template content](' + templateUrl + ')\n' +
'3. 或在创建PR时使用URL参数 | Or use URL parameter when creating PR\n' +
' `?template=' + suggestedTemplate + '.md`\n\n' +
'_这是一个自动建议,您可以继续使用当前模板。_\n\n' +
'_This is an automated suggestion. You may continue using the current template._';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
}
} else if (suggestedTemplate && !usingDefaultTemplate) {
console.log('PR already uses a specific template');
} else {
console.log('No specific template suggestion needed - mixed changes');
}
+3
View File
@@ -41,3 +41,6 @@ web/node_modules/
node_modules/
web/dist/
web/.vite/
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
eslint-*.json
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env sh
if [ -z "$husky_skip_init" ]; then
debug () {
if [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky (debug) - $1"
fi
}
readonly hook_name="$(basename -- "$0")"
debug "starting $hook_name..."
if [ "$HUSKY" = "0" ]; then
debug "HUSKY env variable is set to 0, skipping hook"
exit 0
fi
if [ -f ~/.huskyrc ]; then
debug "sourcing ~/.huskyrc"
. ~/.huskyrc
fi
readonly husky_skip_init=1
export husky_skip_init
sh -e "$0" "$@"
exitCode="$?"
if [ $exitCode != 0 ]; then
echo "husky - $hook_name hook exited with code $exitCode (error)"
fi
if [ $exitCode = 127 ]; then
echo "husky - command not found in PATH=$PATH"
fi
exit $exitCode
fi
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd web && npx lint-staged
+1 -1
View File
@@ -281,7 +281,7 @@ Docker automatically handles all dependencies (Go, Node.js, TA-Lib, SQLite) and
#### Step 1: Prepare Configuration
```bash
# Copy configuration template
cp config.example.jsonc config.json
cp config.json.example config.json
# Edit and fill in your API keys
nano config.json # or use any editor
+204 -36
View File
@@ -9,6 +9,7 @@ import (
"nofx/config"
"nofx/decision"
"nofx/manager"
"nofx/trader"
"strconv"
"strings"
"time"
@@ -88,7 +89,7 @@ func (s *Server) setupRoutes() {
// 系统提示词模板管理(无需认证)
api.GET("/prompt-templates", s.handleGetPromptTemplates)
api.GET("/prompt-templates/:name", s.handleGetPromptTemplate)
// 公开的竞赛数据(无需认证)
api.GET("/traders", s.handlePublicTraderList)
api.GET("/competition", s.handlePublicCompetition)
@@ -109,6 +110,7 @@ func (s *Server) setupRoutes() {
protected.POST("/traders/:id/start", s.handleStartTrader)
protected.POST("/traders/:id/stop", s.handleStopTrader)
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
// AI模型配置
protected.GET("/models", s.handleGetModelConfigs)
@@ -168,7 +170,7 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) {
if val, err := strconv.Atoi(altcoinLeverageStr); err == nil && val > 0 {
altcoinLeverage = val
}
// 获取内测模式配置
betaModeStr, _ := s.database.GetSystemConfig("beta_mode")
betaMode := betaModeStr == "true"
@@ -347,6 +349,73 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
scanIntervalMinutes = 3 // 默认3分钟
}
// ✨ 查询交易所实际余额,覆盖用户输入
actualBalance := req.InitialBalance // 默认使用用户输入
exchanges, err := s.database.GetExchanges(userID)
if err != nil {
log.Printf("⚠️ 获取交易所配置失败,使用用户输入的初始资金: %v", err)
}
// 查找匹配的交易所配置
var exchangeCfg *config.ExchangeConfig
for _, ex := range exchanges {
if ex.ID == req.ExchangeID {
exchangeCfg = ex
break
}
}
if exchangeCfg == nil {
log.Printf("⚠️ 未找到交易所 %s 的配置,使用用户输入的初始资金", req.ExchangeID)
} else if !exchangeCfg.Enabled {
log.Printf("⚠️ 交易所 %s 未启用,使用用户输入的初始资金", req.ExchangeID)
} else {
// 根据交易所类型创建临时 trader 查询余额
var tempTrader trader.Trader
var createErr error
switch req.ExchangeID {
case "binance":
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey)
case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader(
exchangeCfg.APIKey, // private key
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
)
case "aster":
tempTrader, createErr = trader.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
exchangeCfg.AsterPrivateKey,
)
default:
log.Printf("⚠️ 不支持的交易所类型: %s,使用用户输入的初始资金", req.ExchangeID)
}
if createErr != nil {
log.Printf("⚠️ 创建临时 trader 失败,使用用户输入的初始资金: %v", createErr)
} else if tempTrader != nil {
// 查询实际余额
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr)
} else {
// 提取可用余额
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
actualBalance = availableBalance
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
// 有些交易所可能只返回 balance 字段
actualBalance = totalBalance
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance)
} else {
log.Printf("⚠️ 无法从余额信息中提取可用余额,使用用户输入的初始资金")
}
}
}
}
// 创建交易员配置(数据库实体)
trader := &config.TraderRecord{
ID: traderID,
@@ -354,7 +423,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
Name: req.Name,
AIModelID: req.AIModelID,
ExchangeID: req.ExchangeID,
InitialBalance: req.InitialBalance,
InitialBalance: actualBalance, // 使用实际查询的余额
BTCETHLeverage: btcEthLeverage,
AltcoinLeverage: altcoinLeverage,
TradingSymbols: req.TradingSymbols,
@@ -369,7 +438,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
}
// 保存到数据库
err := s.database.CreateTrader(trader)
err = s.database.CreateTrader(trader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建交易员失败: %v", err)})
return
@@ -531,14 +600,14 @@ func (s *Server) handleDeleteTrader(c *gin.Context) {
func (s *Server) handleStartTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// 校验交易员是否属于当前用户
_, _, _, err := s.database.GetTraderConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在或无访问权限"})
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
@@ -574,14 +643,14 @@ func (s *Server) handleStartTrader(c *gin.Context) {
func (s *Server) handleStopTrader(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
// 校验交易员是否属于当前用户
_, _, _, err := s.database.GetTraderConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在或无访问权限"})
return
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
@@ -641,6 +710,113 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"})
}
// handleSyncBalance 同步交易所余额到initial_balance(选项B:手动同步 + 选项C:智能检测)
func (s *Server) handleSyncBalance(c *gin.Context) {
userID := c.GetString("user_id")
traderID := c.Param("id")
log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID)
// 从数据库获取交易员配置(包含交易所信息)
traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"})
return
}
if exchangeCfg == nil || !exchangeCfg.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"})
return
}
// 创建临时 trader 查询余额
var tempTrader trader.Trader
var createErr error
switch traderConfig.ExchangeID {
case "binance":
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey)
case "hyperliquid":
tempTrader, createErr = trader.NewHyperliquidTrader(
exchangeCfg.APIKey,
exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet,
)
case "aster":
tempTrader, createErr = trader.NewAsterTrader(
exchangeCfg.AsterUser,
exchangeCfg.AsterSigner,
exchangeCfg.AsterPrivateKey,
)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的交易所类型"})
return
}
if createErr != nil {
log.Printf("⚠️ 创建临时 trader 失败: %v", createErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)})
return
}
// 查询实际余额
balanceInfo, balanceErr := tempTrader.GetBalance()
if balanceErr != nil {
log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)})
return
}
// 提取可用余额
var actualBalance float64
if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 {
actualBalance = availableBalance
} else if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 {
actualBalance = availableBalance
} else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 {
actualBalance = totalBalance
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法获取可用余额"})
return
}
oldBalance := traderConfig.InitialBalance
// ✅ 选项C:智能检测余额变化
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
changeType := "增加"
if changePercent < 0 {
changeType = "减少"
}
log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)",
actualBalance, oldBalance, changePercent)
// 更新数据库中的 initial_balance
err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance)
if err != nil {
log.Printf("❌ 更新initial_balance失败: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"})
return
}
// 重新加载交易员到内存
err = s.traderManager.LoadUserTraders(s.database, userID)
if err != nil {
log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err)
}
log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
c.JSON(http.StatusOK, gin.H{
"message": "余额同步成功",
"old_balance": oldBalance,
"new_balance": actualBalance,
"change_percent": changePercent,
"change_type": changeType,
})
}
// handleGetModelConfigs 获取AI模型配置
func (s *Server) handleGetModelConfigs(c *gin.Context) {
userID := c.GetString("user_id")
@@ -791,19 +967,12 @@ func (s *Server) handleTraderList(c *gin.Context) {
}
}
// AIModelID 应该已经是 provider(如 "deepseek"),直接使用
// 如果是旧数据格式(如 "admin_deepseek"),提取 provider 部分
aiModelID := trader.AIModelID
// 兼容旧数据:如果包含下划线,提取最后一部分作为 provider
if strings.Contains(aiModelID, "_") {
parts := strings.Split(aiModelID, "_")
aiModelID = parts[len(parts)-1]
}
// 返回完整的 AIModelID(如 "admin_deepseek"),不要截断
// 前端需要完整 ID 来验证模型是否存在(与 handleGetTraderConfig 保持一致)
result = append(result, map[string]interface{}{
"trader_id": trader.ID,
"trader_name": trader.Name,
"ai_model": aiModelID,
"ai_model": trader.AIModelID, // 使用完整 ID
"exchange_id": trader.ExchangeID,
"is_running": isRunning,
"initial_balance": trader.InitialBalance,
@@ -1581,7 +1750,7 @@ func (s *Server) handlePublicCompetition(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, competition)
}
@@ -1594,7 +1763,7 @@ func (s *Server) handleTopTraders(c *gin.Context) {
})
return
}
c.JSON(http.StatusOK, topTraders)
}
@@ -1603,7 +1772,7 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
var requestBody struct {
TraderIDs []string `json:"trader_ids"`
}
// 尝试解析POST请求的JSON body
if err := c.ShouldBindJSON(&requestBody); err != nil {
// 如果JSON解析失败,尝试从query参数获取(兼容GET请求)
@@ -1617,13 +1786,13 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
})
return
}
traders, ok := topTraders["traders"].([]map[string]interface{})
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "交易员数据格式错误"})
return
}
// 提取trader IDs
traderIDs := make([]string, 0, len(traders))
for _, trader := range traders {
@@ -1631,24 +1800,24 @@ func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
traderIDs = append(traderIDs, traderID)
}
}
result := s.getEquityHistoryForTraders(traderIDs)
c.JSON(http.StatusOK, result)
return
}
// 解析逗号分隔的trader IDs
requestBody.TraderIDs = strings.Split(traderIDsParam, ",")
for i := range requestBody.TraderIDs {
requestBody.TraderIDs[i] = strings.TrimSpace(requestBody.TraderIDs[i])
}
}
// 限制最多20个交易员,防止请求过大
if len(requestBody.TraderIDs) > 20 {
requestBody.TraderIDs = requestBody.TraderIDs[:20]
}
result := s.getEquityHistoryForTraders(requestBody.TraderIDs)
c.JSON(http.StatusOK, result)
}
@@ -1658,31 +1827,31 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter
result := make(map[string]interface{})
histories := make(map[string]interface{})
errors := make(map[string]string)
for _, traderID := range traderIDs {
if traderID == "" {
continue
}
trader, err := s.traderManager.GetTrader(traderID)
if err != nil {
errors[traderID] = "交易员不存在"
continue
}
// 获取历史数据(用于对比展示,限制数据量)
records, err := trader.GetDecisionLogger().GetLatestRecords(500)
if err != nil {
errors[traderID] = fmt.Sprintf("获取历史数据失败: %v", err)
continue
}
// 构建收益率历史数据
history := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
// 计算总权益(余额+未实现盈亏)
totalEquity := record.AccountState.TotalBalance + record.AccountState.TotalUnrealizedProfit
history = append(history, map[string]interface{}{
"timestamp": record.Timestamp,
"total_equity": totalEquity,
@@ -1690,16 +1859,16 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter
"balance": record.AccountState.TotalBalance,
})
}
histories[traderID] = history
}
result["histories"] = histories
result["count"] = len(histories)
if len(errors) > 0 {
result["errors"] = errors
}
return result
}
@@ -1733,4 +1902,3 @@ func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
c.JSON(http.StatusOK, result)
}
+4 -1
View File
@@ -20,5 +20,8 @@
"max_daily_loss": 10.0,
"max_drawdown": 20.0,
"stop_trading_minutes": 60,
"jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg=="
"jwt_secret": "Qk0kAa+d0iIEzXVHXbNbm+UaN3RNabmWtH8rDWZ5OPf+4GX8pBflAHodfpbipVMyrw1fsDanHsNBjhgbDeK9Jg==",
"log": {
"level": "info"
}
}
+15
View File
@@ -50,6 +50,20 @@ type LeverageConfig struct {
AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币的杠杆倍数(主账户建议5-20,子账户≤5)
}
// LogConfig 日志配置
type LogConfig struct {
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选)
}
// TelegramConfig Telegram推送配置(简化版,只保留必需字段)
type TelegramConfig struct {
Enabled bool `json:"enabled"` // 是否启用(默认: false
BotToken string `json:"bot_token"` // Bot Token
ChatID int64 `json:"chat_id"` // Chat ID
MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error
}
// Config 总配置
type Config struct {
Traders []TraderConfig `json:"traders"`
@@ -60,6 +74,7 @@ type Config struct {
MaxDrawdown float64 `json:"max_drawdown"`
StopTradingMinutes int `json:"stop_trading_minutes"`
Leverage LeverageConfig `json:"leverage"` // 杠杆配置
Log *LogConfig `json:"log"` // 日志配置(可选)
}
// LoadConfig 从文件加载配置
+12 -12
View File
@@ -258,17 +258,17 @@ func (d *Database) initDefaultData() error {
// 初始化系统配置 - 创建所有字段,设置默认值,后续由config.json同步更新
systemConfigs := map[string]string{
"admin_mode": "true", // 默认开启管理员模式,便于首次使用
"beta_mode": "false", // 默认关闭内测模式
"api_server_port": "8080", // 默认API端口
"use_default_coins": "true", // 默认使用内置币种列表
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, // 默认币种列表(JSON格式)
"max_daily_loss": "10.0", // 最大日损失百分比
"max_drawdown": "20.0", // 最大回撤百分比
"stop_trading_minutes": "60", // 停止交易时间(分钟)
"btc_eth_leverage": "5", // BTC/ETH杠杆倍数
"altcoin_leverage": "5", // 山寨币杠杆倍数
"jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成
"admin_mode": "true", // 默认开启管理员模式,便于首次使用
"beta_mode": "false", // 默认关闭内测模式
"api_server_port": "8080", // 默认API端口
"use_default_coins": "true", // 默认使用内置币种列表
"default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, // 默认币种列表(JSON格式)
"max_daily_loss": "10.0", // 最大日损失百分比
"max_drawdown": "20.0", // 最大回撤百分比
"stop_trading_minutes": "60", // 停止交易时间(分钟)
"btc_eth_leverage": "5", // BTC/ETH杠杆倍数
"altcoin_leverage": "5", // 山寨币杠杆倍数
"jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成
}
for key, value := range systemConfigs {
@@ -1043,7 +1043,7 @@ func (d *Database) LoadBetaCodesFromFile(filePath string) error {
log.Printf("插入内测码 %s 失败: %v", code, err)
continue
}
if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 {
insertedCount++
}
+189 -30
View File
@@ -7,10 +7,22 @@ import (
"nofx/market"
"nofx/mcp"
"nofx/pool"
"regexp"
"strings"
"time"
)
// 预编译正则表达式(性能优化:避免每次调用时重新编译)
var (
// ✅ 安全的正則:精確匹配 ```json 代碼塊
// 使用反引號 + 拼接避免轉義問題
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
)
// PositionInfo 持仓信息
type PositionInfo struct {
Symbol string `json:"symbol"`
@@ -71,11 +83,20 @@ type Context struct {
// Decision AI的交易决策
type Decision struct {
Symbol string `json:"symbol"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait"
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "update_stop_loss", "update_take_profit", "partial_close", "hold", "wait"
// 开仓参数
Leverage int `json:"leverage,omitempty"`
PositionSizeUSD float64 `json:"position_size_usd,omitempty"`
StopLoss float64 `json:"stop_loss,omitempty"`
TakeProfit float64 `json:"take_profit,omitempty"`
// 调整参数(新增)
NewStopLoss float64 `json:"new_stop_loss,omitempty"` // 用于 update_stop_loss
NewTakeProfit float64 `json:"new_take_profit,omitempty"` // 用于 update_take_profit
ClosePercentage float64 `json:"close_percentage,omitempty"` // 用于 partial_close (0-100)
// 通用参数
Confidence int `json:"confidence,omitempty"` // 信心度 (0-100)
RiskUSD float64 `json:"risk_usd,omitempty"` // 最大美元风险
Reasoning string `json:"reasoning"`
@@ -160,17 +181,20 @@ func fetchMarketDataForContext(ctx *Context) error {
continue
}
// ⚠️ 流动性过滤:持仓价值低于15M USD的币种不做(多空都不做)
// ⚠️ 流动性过滤:持仓价值低于阈值的币种不做(多空都不做)
// 持仓价值 = 持仓量 × 当前价格
// 但现有持仓必须保留(需要决策是否平仓)
// 💡 OI 門檻配置:用戶可根據風險偏好調整
const minOIThresholdMillions = 15.0 // 可調整:15M(保守) / 10M(平衡) / 8M(寬鬆) / 5M(激進)
isExistingPosition := positionSymbols[symbol]
if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 {
// 计算持仓价值(USD)= 持仓量 × 当前价格
oiValue := data.OpenInterest.Latest * data.CurrentPrice
oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位
if oiValueInMillions < 15 {
log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < 15M),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
symbol, oiValueInMillions, data.OpenInterest.Latest, data.CurrentPrice)
if oiValueInMillions < minOIThresholdMillions {
log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice)
continue
}
}
@@ -200,10 +224,31 @@ func fetchMarketDataForContext(ctx *Context) error {
// calculateMaxCandidates 根据账户状态计算需要分析的候选币种数量
func calculateMaxCandidates(ctx *Context) int {
// 直接返回候选池的全部币种数量
// 因为候选池已经在 auto_trader.go 中筛选过了
// 固定分析前20个评分最高的币种(来自AI500)
return len(ctx.CandidateCoins)
// ⚠️ 重要:限制候选币种数量,避免 Prompt 过大
// 根据持仓数量动态调整:持仓越少,可以分析更多候选币
const (
maxCandidatesWhenEmpty = 30 // 无持仓时最多分析30个候选币
maxCandidatesWhenHolding1 = 25 // 持仓1个时最多分析25个候选币
maxCandidatesWhenHolding2 = 20 // 持仓2个时最多分析20个候选币
maxCandidatesWhenHolding3 = 15 // 持仓3个时最多分析15个候选币(避免 Prompt 过大)
)
positionCount := len(ctx.Positions)
var maxCandidates int
switch positionCount {
case 0:
maxCandidates = maxCandidatesWhenEmpty
case 1:
maxCandidates = maxCandidatesWhenHolding1
case 2:
maxCandidates = maxCandidatesWhenHolding2
default: // 3+ 持仓
maxCandidates = maxCandidatesWhenHolding3
}
// 返回实际候选币数量和上限中的较小值
return min(len(ctx.CandidateCoins), maxCandidates)
}
// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt
@@ -430,25 +475,44 @@ func extractCoTTrace(response string) string {
// extractDecisions 提取JSON决策列表
func extractDecisions(response string) ([]Decision, error) {
// 直接查找JSON数组 - 找第一个完整的JSON数组
arrayStart := strings.Index(response, "[")
if arrayStart == -1 {
return nil, fmt.Errorf("无法找到JSON数组起始")
// 预清洗:去零宽/BOM
s := removeInvisibleRunes(response)
s = strings.TrimSpace(s)
// 🔧 關鍵修復:在正則匹配之前就先修復全角字符!
// 否則正則表達式 \[ 無法匹配全角的 [
s = fixMissingQuotes(s)
// 1) 优先从 ```json 代码块中提取
if m := reJSONFence.FindStringSubmatch(s); m != nil && len(m) > 1 {
jsonContent := strings.TrimSpace(m[1])
jsonContent = compactArrayOpen(jsonContent) // 把 "[ {" 规整为 "[{"
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有全角)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent)
}
return decisions, nil
}
// 从 [ 开始,匹配括号找到对应的 ]
arrayEnd := findMatchingBracket(response, arrayStart)
if arrayEnd == -1 {
return nil, fmt.Errorf("无法找到JSON数组结束")
// 2) 退而求其次:全文寻找首个对象数组
// 注意:此時 s 已經過 fixMissingQuotes(),全角字符已轉換為半角
jsonContent := strings.TrimSpace(reJSONArray.FindString(s))
if jsonContent == "" {
return nil, fmt.Errorf("无法找到JSON数组起始(已嘗試修復全角字符)\n原始響應前200字符: %s", s[:min(200, len(s))])
}
jsonContent := strings.TrimSpace(response[arrayStart : arrayEnd+1])
// 🔧 規整格式(此時全角字符已在前面修復過)
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有殘留全角)
// 🔧 修复常见的JSON格式错误:缺少引号的字段值
// 匹配: "reasoning": 内容"} 或 "reasoning": 内容} (没有引号)
// 修复为: "reasoning": "内容"}
// 使用简单的字符串扫描而不是正则表达式
jsonContent = fixMissingQuotes(jsonContent)
// 🔧 验证 JSON 格式(检测常见错误)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
}
// 解析JSON
var decisions []Decision
@@ -459,15 +523,86 @@ func extractDecisions(response string) ([]Decision, error) {
return decisions, nil
}
// fixMissingQuotes 替换中文引号为英文引号(避免输入法自动转换
// fixMissingQuotes 替换中文引号和全角字符为英文引号和半角字符(避免AI输出全角JSON字符导致解析失败
func fixMissingQuotes(jsonStr string) string {
// 替换中文引号
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // "
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // "
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // '
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // '
// ⚠️ 替换全角括号、冒号、逗号(防止AI输出全角JSON字符)
jsonStr = strings.ReplaceAll(jsonStr, "", "[") // U+FF3B 全角左方括号
jsonStr = strings.ReplaceAll(jsonStr, "", "]") // U+FF3D 全角右方括号
jsonStr = strings.ReplaceAll(jsonStr, "", "{") // U+FF5B 全角左花括号
jsonStr = strings.ReplaceAll(jsonStr, "", "}") // U+FF5D 全角右花括号
jsonStr = strings.ReplaceAll(jsonStr, "", ":") // U+FF1A 全角冒号
jsonStr = strings.ReplaceAll(jsonStr, "", ",") // U+FF0C 全角逗号
// ⚠️ 替换CJK标点符号(AI在中文上下文中也可能输出这些)
jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK左方头括号 U+3010
jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK右方头括号 U+3011
jsonStr = strings.ReplaceAll(jsonStr, "", "[") // CJK左龟壳括号 U+3014
jsonStr = strings.ReplaceAll(jsonStr, "", "]") // CJK右龟壳括号 U+3015
jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK顿号 U+3001
// ⚠️ 替换全角空格为半角空格(JSON中不应该有全角空格)
jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 全角空格
return jsonStr
}
// validateJSONFormat 验证 JSON 格式,检测常见错误
func validateJSONFormat(jsonStr string) error {
trimmed := strings.TrimSpace(jsonStr)
// 允许 [ 和 { 之间存在任意空白(含零宽)
if !reArrayHead.MatchString(trimmed) {
// 检查是否是纯数字/范围数组(常见错误)
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
return fmt.Errorf("不是有效的决策数组(必须包含对象 {}),实际内容: %s", trimmed[:min(50, len(trimmed))])
}
return fmt.Errorf("JSON 必须以 [{ 开头(允许空白),实际: %s", trimmed[:min(20, len(trimmed))])
}
// 检查是否包含范围符号 ~(LLM 常见错误)
if strings.Contains(jsonStr, "~") {
return fmt.Errorf("JSON 中不可包含范围符号 ~,所有数字必须是精确的单一值")
}
// 检查是否包含千位分隔符(如 98,000)
// 使用简单的模式匹配:数字+逗号+3位数字
for i := 0; i < len(jsonStr)-4; i++ {
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
jsonStr[i+1] == ',' &&
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
return fmt.Errorf("JSON 数字不可包含千位分隔符逗号,发现: %s", jsonStr[i:min(i+10, len(jsonStr))])
}
}
return nil
}
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// removeInvisibleRunes 去除零宽字符和 BOM,避免肉眼看不见的前缀破坏校验
func removeInvisibleRunes(s string) string {
return reInvisibleRunes.ReplaceAllString(s, "")
}
// compactArrayOpen 规整开头的 "[ {" → "[{"
func compactArrayOpen(s string) string {
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
}
// validateDecisions 验证所有决策(需要账户信息和杠杆配置)
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
for i, decision := range decisions {
@@ -504,12 +639,15 @@ func findMatchingBracket(s string, start int) int {
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
// 验证action
validActions := map[string]bool{
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"hold": true,
"wait": true,
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"update_stop_loss": true,
"update_take_profit": true,
"partial_close": true,
"hold": true,
"wait": true,
}
if !validActions[d.Action] {
@@ -589,5 +727,26 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
}
}
// 动态调整止损验证
if d.Action == "update_stop_loss" {
if d.NewStopLoss <= 0 {
return fmt.Errorf("新止损价格必须大于0: %.2f", d.NewStopLoss)
}
}
// 动态调整止盈验证
if d.Action == "update_take_profit" {
if d.NewTakeProfit <= 0 {
return fmt.Errorf("新止盈价格必须大于0: %.2f", d.NewTakeProfit)
}
}
// 部分平仓验证
if d.Action == "partial_close" {
if d.ClosePercentage <= 0 || d.ClosePercentage > 100 {
return fmt.Errorf("平仓百分比必须在0-100之间: %.1f", d.ClosePercentage)
}
}
return nil
}
+1
View File
@@ -17,6 +17,7 @@ services:
- /etc/localtime:/etc/localtime:ro # Sync host time
environment:
- TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone
- AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000
networks:
- nofx-network
healthcheck:
+1 -1
View File
@@ -23,7 +23,7 @@ Choose the method that best fits your needs:
**Quick Start:**
```bash
cp config.example.jsonc config.json
cp config.json.example config.json
./start.sh start --build
```
+1 -1
View File
@@ -21,7 +21,7 @@
**快速开始:**
```bash
cp config.example.jsonc config.json
cp config.json.example config.json
./start.sh start --build
```
+2 -2
View File
@@ -50,7 +50,7 @@ docker compose --version # Docker 24+ includes this, no separate installation n
```bash
# Copy configuration template
cp config.example.jsonc config.json
cp config.json.example config.json
# Edit configuration file with your API keys
nano config.json # or use any other editor
@@ -267,7 +267,7 @@ kill -9 <PID>
# ~~ls -la config.json~~
# ~~If not exists, copy template~~
# ~~cp config.example.jsonc config.json~~
# ~~cp config.json.example config.json~~
*Note: Now using SQLite database for configuration storage, no longer need config.json*
```
+2 -2
View File
@@ -55,7 +55,7 @@ docker compose --version # Docker 24+ 自带,无需单独安装
```bash
# 复制配置文件模板
cp config.example.jsonc config.json
cp config.json.example config.json
# 编辑配置文件,填入你的 API 密钥
nano config.json # 或使用其他编辑器
@@ -270,7 +270,7 @@ kill -9 <PID>
ls -la config.json
# 如果不存在,复制模板
cp config.example.jsonc config.json
cp config.json.example config.json
```
### 健康检查失败
+2 -2
View File
@@ -285,7 +285,7 @@ Docker автоматически обрабатывает все зависим
#### Шаг 1: Подготовьте конфигурацию
```bash
# Скопируйте шаблон конфигурации
cp config.example.jsonc config.json
cp config.json.example config.json
# Отредактируйте и заполните ваши API ключи
nano config.json # или используйте любой редактор
@@ -423,7 +423,7 @@ cd ..
**Шаг 1**: Скопируйте и переименуйте файл примера конфигурации
```bash
cp config.example.jsonc config.json
cp config.json.example config.json
```
**Шаг 2**: Отредактируйте `config.json` и заполните ваши API ключи
+2 -2
View File
@@ -288,7 +288,7 @@ Docker автоматично обробляє всі залежності (Go,
#### Крок 1: Підготуйте конфігурацію
```bash
# Скопіюйте шаблон конфігурації
cp config.example.jsonc config.json
cp config.json.example config.json
# Відредагуйте та заповніть ваші API ключі
nano config.json # або використайте будь-який редактор
@@ -426,7 +426,7 @@ cd ..
**Крок 1**: Скопіюйте та перейменуйте файл прикладу конфігурації
```bash
cp config.example.jsonc config.json
cp config.json.example config.json
```
**Крок 2**: Відредагуйте `config.json` та заповніть ваші API ключі
+2 -2
View File
@@ -283,7 +283,7 @@ Docker会自动处理所有依赖(Go、Node.js、TA-Lib)和环境配置,
#### 步骤1:准备配置文件
```bash
# 复制配置文件模板
cp config.example.jsonc config.json
cp config.json.example config.json
# 编辑并填入你的API密钥
nano config.json # 或使用其他编辑器
@@ -422,7 +422,7 @@ cd ..
~~**步骤1**:复制并重命名示例配置文件~~
```bash
cp config.example.jsonc config.json
cp config.json.example config.json
```
~~**步骤2**:编辑`config.json`填入您的API密钥~~
+3 -1
View File
@@ -6,11 +6,13 @@ require (
github.com/adshao/go-binance/v2 v2.8.7
github.com/ethereum/go-ethereum v1.16.5
github.com/gin-gonic/gin v1.11.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.16
github.com/mattn/go-sqlite3 v1.14.22
github.com/pquerna/otp v1.4.0
github.com/sirupsen/logrus v1.9.3
github.com/sonirico/go-hyperliquid v0.17.0
golang.org/x/crypto v0.42.0
)
+8 -2
View File
@@ -62,6 +62,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -118,8 +120,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
@@ -155,6 +157,8 @@ github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
@@ -167,6 +171,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -206,6 +211,7 @@ golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+64
View File
@@ -0,0 +1,64 @@
package logger
import (
"github.com/sirupsen/logrus"
)
// Config 日志配置(简化版)
type Config struct {
Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info)
Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选)
}
// TelegramConfig Telegram推送配置(简化版,高级参数使用默认值)
type TelegramConfig struct {
Enabled bool `json:"enabled"` // 是否启用(默认: false
BotToken string `json:"bot_token"` // Bot Token
ChatID int64 `json:"chat_id"` // Chat ID
MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error
}
// SetDefaults 设置默认值
func (c *Config) SetDefaults() {
if c.Level == "" {
c.Level = "info"
}
}
// GetLogrusLevels 返回要推送到Telegram的日志级别
// 根据配置的MinLevel返回该级别及以上的所有日志级别
// 如果未配置或配置无效,默认返回error, fatal, panic(向后兼容)
func (tc *TelegramConfig) GetLogrusLevels() []logrus.Level {
// 如果未配置,使用默认值error(向后兼容)
minLevelStr := tc.MinLevel
if minLevelStr == "" {
minLevelStr = "error"
}
// 解析配置的日志级别
minLevel, err := logrus.ParseLevel(minLevelStr)
if err != nil {
// 如果解析失败,使用默认值error(向后兼容)
minLevel = logrus.ErrorLevel
}
// 定义所有日志级别(从高到低:panic, fatal, error, warn, info, debug
allLevels := []logrus.Level{
logrus.PanicLevel,
logrus.FatalLevel,
logrus.ErrorLevel,
logrus.WarnLevel,
logrus.InfoLevel,
logrus.DebugLevel,
}
// 返回所有大于等于minLevel的日志级别
var result []logrus.Level
for _, level := range allLevels {
if level <= minLevel {
result = append(result, level)
}
}
return result
}
+33
View File
@@ -0,0 +1,33 @@
{
"traders": [
{
"id": "trader1",
"name": "AI Trader 1",
"enabled": true,
"ai_model": "deepseek",
"exchange": "binance",
"binance_api_key": "your_api_key",
"binance_secret_key": "your_secret_key",
"deepseek_key": "your_deepseek_key",
"initial_balance": 1000,
"scan_interval_minutes": 3
}
],
"use_default_coins": true,
"default_coins": ["BTCUSDT", "ETHUSDT", "SOLUSDT"],
"api_server_port": 8080,
"leverage": {
"btc_eth_leverage": 5,
"altcoin_leverage": 5
},
"log": {
"level": "info",
"telegram": {
"enabled": true,
"bot_token": "79472419:feafe231414",
"chat_id": -100323252626,
"min_level": "error"
}
},
"_comment": "日志配置说明:level 可选值为 debug/info/warn/error,默认 info。telegram 部分作为可选配置, Telegram 推送默认为 error/fatal/panic 级别,min_level 如果设置为warn,则推送warn级别及以上的日志"
}
+46 -13
View File
@@ -50,9 +50,9 @@ type PositionSnapshot struct {
// DecisionAction 决策动作
type DecisionAction struct {
Action string `json:"action"` // open_long, open_short, close_long, close_short
Action string `json:"action"` // open_long, open_short, close_long, close_short, update_stop_loss, update_take_profit, partial_close
Symbol string `json:"symbol"` // 币种
Quantity float64 `json:"quantity"` // 数量
Quantity float64 `json:"quantity"` // 数量(部分平仓时使用)
Leverage int `json:"leverage"` // 杠杆(开仓时)
Price float64 `json:"price"` // 执行价格
OrderID int64 `json:"order_id"` // 订单ID
@@ -243,8 +243,9 @@ func (l *DecisionLogger) GetStatistics() (*Statistics, error) {
switch action.Action {
case "open_long", "open_short":
stats.TotalOpenPositions++
case "close_long", "close_short":
case "close_long", "close_short", "partial_close":
stats.TotalClosePositions++
// update_stop_loss 和 update_take_profit 不計入統計
}
}
}
@@ -348,11 +349,22 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
symbol := action.Symbol
side := ""
if action.Action == "open_long" || action.Action == "close_long" {
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" {
side = "long"
} else if action.Action == "open_short" || action.Action == "close_short" {
side = "short"
}
// partial_close 需要根據持倉判斷方向
if action.Action == "partial_close" && side == "" {
for key, pos := range openPositions {
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
side = posSymbol
break
}
}
}
posKey := symbol + "_" + side
switch action.Action {
@@ -368,6 +380,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
case "close_long", "close_short":
// 移除已平仓记录
delete(openPositions, posKey)
// partial_close 不處理,保留持倉記錄
}
}
}
@@ -382,11 +395,23 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
symbol := action.Symbol
side := ""
if action.Action == "open_long" || action.Action == "close_long" {
if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" {
side = "long"
} else if action.Action == "open_short" || action.Action == "close_short" {
side = "short"
}
// partial_close 需要根據持倉判斷方向
if action.Action == "partial_close" {
// 從 openPositions 中查找持倉方向
for key, pos := range openPositions {
if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol {
side = posSymbol
break
}
}
}
posKey := symbol + "_" + side // 使用symbol_side作为key,区分多空持仓
switch action.Action {
@@ -400,7 +425,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
"leverage": action.Leverage,
}
case "close_long", "close_short":
case "close_long", "close_short", "partial_close":
// 查找对应的开仓记录(可能来自预填充或当前窗口)
if openPos, exists := openPositions[posKey]; exists {
openPrice := openPos["openPrice"].(float64)
@@ -409,18 +434,24 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
quantity := openPos["quantity"].(float64)
leverage := openPos["leverage"].(int)
// 对于 partial_close,使用实际平仓数量;否则使用完整仓位数量
actualQuantity := quantity
if action.Action == "partial_close" {
actualQuantity = action.Quantity
}
// 计算实际盈亏(USDT
// 合约交易 PnL 计算:quantity × 价格差
// 合约交易 PnL 计算:actualQuantity × 价格差
// 注意:杠杆不影响绝对盈亏,只影响保证金需求
var pnl float64
if side == "long" {
pnl = quantity * (action.Price - openPrice)
pnl = actualQuantity * (action.Price - openPrice)
} else {
pnl = quantity * (openPrice - action.Price)
pnl = actualQuantity * (openPrice - action.Price)
}
// 计算盈亏百分比(相对保证金)
positionValue := quantity * openPrice
positionValue := actualQuantity * openPrice
marginUsed := positionValue / float64(leverage)
pnlPct := 0.0
if marginUsed > 0 {
@@ -431,7 +462,7 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
outcome := TradeOutcome{
Symbol: symbol,
Side: side,
Quantity: quantity,
Quantity: actualQuantity,
Leverage: leverage,
OpenPrice: openPrice,
ClosePrice: action.Price,
@@ -472,8 +503,10 @@ func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAna
stats.LosingTrades++
}
// 移除已平仓记录
delete(openPositions, posKey)
// 移除已平仓记录partial_close 不刪除,因為還有剩餘倉位)
if action.Action != "partial_close" {
delete(openPositions, posKey)
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package logger
import (
"nofx/config"
"os"
"github.com/sirupsen/logrus"
)
var (
// Log 全局logger实例
Log *logrus.Logger
// telegramHook 保存hook引用,用于优雅关闭
telegramHook *TelegramHook
)
// ============================================================================
// 初始化函数
// ============================================================================
// Init 初始化全局logger
// 如果config为nil,使用默认配置(console输出,info级别)
func Init(cfg *Config) error {
Log = logrus.New()
// 如果没有配置,使用默认值
if cfg == nil {
cfg = &Config{Level: "info"}
}
// 设置默认值
cfg.SetDefaults()
// 设置日志级别
level, err := logrus.ParseLevel(cfg.Level)
if err != nil {
level = logrus.InfoLevel
}
Log.SetLevel(level)
// 设置格式化器(固定使用彩色文本格式)
Log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
ForceColors: true,
})
// 设置输出目标(默认stdout
Log.SetOutput(os.Stdout)
// 启用调用位置信息
Log.SetReportCaller(true)
// 添加Telegram Hook(可选)
if cfg.Telegram != nil && cfg.Telegram.Enabled {
if err := setupTelegramHook(cfg.Telegram); err != nil {
Log.Warnf("初始化Telegram推送失败,将继续使用普通日志: %v", err)
}
}
return nil
}
// setupTelegramHook 设置Telegram Hook
func setupTelegramHook(telegramCfg *TelegramConfig) error {
hook, err := NewTelegramHook(telegramCfg)
if err != nil {
return err
}
Log.AddHook(hook)
telegramHook = hook
Log.Info("✅ Telegram日志推送已启用")
return nil
}
// InitWithSimpleConfig 使用简化配置初始化logger
// 适用于只需要基本功能的场景
func InitWithSimpleConfig(level string) error {
return Init(&Config{Level: level})
}
// InitWithTelegram 使用Telegram配置初始化logger
func InitWithTelegram(botToken string, chatID int64) error {
return Init(&Config{
Level: "info",
Telegram: &TelegramConfig{
Enabled: true,
BotToken: botToken,
ChatID: chatID,
},
})
}
// InitFromLogConfig 从config.LogConfig初始化logger
func InitFromLogConfig(logConfig *config.LogConfig) error {
if logConfig == nil {
return InitWithSimpleConfig("info")
}
cfg := &Config{
Level: logConfig.Level,
}
if cfg.Level == "" {
cfg.Level = "info"
}
// 如果启用了Telegram,添加配置
if logConfig.Telegram != nil && logConfig.Telegram.Enabled {
if botToken := logConfig.Telegram.BotToken; botToken != "" && logConfig.Telegram.ChatID != 0 {
cfg.Telegram = &TelegramConfig{
Enabled: true,
BotToken: botToken,
ChatID: logConfig.Telegram.ChatID,
MinLevel: logConfig.Telegram.MinLevel,
}
}
}
return Init(cfg)
}
// InitFromParams 从参数初始化logger
// 适用于不依赖config包的场景
func InitFromParams(level string, telegramEnabled bool, botToken string, chatID int64) error {
cfg := &Config{Level: level}
if telegramEnabled && botToken != "" && chatID != 0 {
cfg.Telegram = &TelegramConfig{
Enabled: true,
BotToken: botToken,
ChatID: chatID,
}
}
return Init(cfg)
}
// Shutdown 优雅关闭logger(主要用于关闭Telegram发送器)
func Shutdown() {
if telegramHook != nil {
telegramHook.Stop()
telegramHook = nil
}
}
// ============================================================================
// 日志记录函数
// ============================================================================
// WithFields 创建带字段的logger entry
func WithFields(fields logrus.Fields) *logrus.Entry {
return Log.WithFields(fields)
}
// WithField 创建带单个字段的logger entry
func WithField(key string, value interface{}) *logrus.Entry {
return Log.WithField(key, value)
}
// add debug, info, warn
func Debug(args ...interface{}) {
Log.Debug(args...)
}
func Info(args ...interface{}) {
Log.Info(args...)
}
func Warn(args ...interface{}) {
Log.Warn(args...)
}
func Debugf(format string, args ...interface{}) {
Log.Debugf(format, args...)
}
func Infof(format string, args ...interface{}) {
Log.Infof(format, args...)
}
func Warnf(format string, args ...interface{}) {
Log.Warnf(format, args...)
}
func Error(args ...interface{}) {
Log.Error(args...)
}
func Errorf(format string, args ...interface{}) {
Log.Errorf(format, args...)
}
func Fatal(args ...interface{}) {
Log.Fatal(args...)
}
func Fatalf(format string, args ...interface{}) {
Log.Fatalf(format, args...)
}
func Panic(args ...interface{}) {
Log.Panic(args...)
}
func Panicf(format string, args ...interface{}) {
Log.Panicf(format, args...)
}
+158
View File
@@ -0,0 +1,158 @@
package logger
import (
"fmt"
"runtime"
"strings"
"github.com/sirupsen/logrus"
)
// TelegramHook 实现logrus.Hook接口,将日志推送到Telegram
type TelegramHook struct {
sender *TelegramSender
levels []logrus.Level
enabled bool
}
// NewTelegramHook 创建Telegram Hook
func NewTelegramHook(config *TelegramConfig) (*TelegramHook, error) {
if !config.Enabled {
return &TelegramHook{enabled: false}, nil
}
if config.BotToken == "" || config.ChatID == 0 {
return nil, fmt.Errorf("telegram配置不完整: bot_token和chat_id不能为空")
}
// 创建发送器(使用默认参数)
sender, err := NewTelegramSender(config.BotToken, config.ChatID)
if err != nil {
return nil, fmt.Errorf("创建telegram发送器失败: %w", err)
}
hook := &TelegramHook{
sender: sender,
levels: config.GetLogrusLevels(),
enabled: true,
}
return hook, nil
}
// Levels 返回需要触发的日志级别
func (h *TelegramHook) Levels() []logrus.Level {
if !h.enabled {
return []logrus.Level{}
}
return h.levels
}
// Fire 当日志触发时调用
func (h *TelegramHook) Fire(entry *logrus.Entry) error {
if !h.enabled {
return nil
}
// 格式化消息
message := h.formatMessage(entry)
// 异步发送(非阻塞)
h.sender.SendAsync(message)
return nil
}
// formatMessage 格式化日志消息为Telegram格式
func (h *TelegramHook) formatMessage(entry *logrus.Entry) string {
// 级别emoji
levelEmoji := h.getLevelEmoji(entry.Level)
// 基本信息
var builder strings.Builder
builder.WriteString(fmt.Sprintf("%s *%s*: 系统日志警报\n", levelEmoji, strings.ToUpper(entry.Level.String())))
builder.WriteString(fmt.Sprintf("📝 消息: `%s`\n", escapeMarkdown(entry.Message)))
// 字段信息
if len(entry.Data) > 0 {
builder.WriteString("📊 字段:\n")
for key, value := range entry.Data {
builder.WriteString(fmt.Sprintf(" • %s: `%v`\n", key, value))
}
}
// 调用位置
if entry.HasCaller() {
file := entry.Caller.File
// 只保留相对路径
if idx := strings.Index(file, "nofx/"); idx >= 0 {
file = file[idx:]
}
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, entry.Caller.Line))
} else {
// 如果entry没有caller,手动获取
if _, file, line, ok := runtime.Caller(8); ok {
if idx := strings.Index(file, "nofx/"); idx >= 0 {
file = file[idx:]
}
builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, line))
}
}
// 时间戳
builder.WriteString(fmt.Sprintf("🕐 时间: `%s`", entry.Time.Format("2006-01-02 15:04:05")))
return builder.String()
}
// getLevelEmoji 获取日志级别对应的emoji
func (h *TelegramHook) getLevelEmoji(level logrus.Level) string {
switch level {
case logrus.PanicLevel:
return "🔴"
case logrus.FatalLevel:
return "🔴"
case logrus.ErrorLevel:
return "🟠"
case logrus.WarnLevel:
return "🟡"
case logrus.InfoLevel:
return "🟢"
case logrus.DebugLevel:
return "🔵"
default:
return "⚪"
}
}
// escapeMarkdown 转义Markdown特殊字符
func escapeMarkdown(text string) string {
replacer := strings.NewReplacer(
"_", "\\_",
"*", "\\*",
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"~", "\\~",
"`", "\\`",
">", "\\>",
"#", "\\#",
"+", "\\+",
"-", "\\-",
"=", "\\=",
"|", "\\|",
"{", "\\{",
"}", "\\}",
".", "\\.",
"!", "\\!",
)
return replacer.Replace(text)
}
// Stop 停止Hook(优雅关闭)
func (h *TelegramHook) Stop() {
if h.enabled && h.sender != nil {
h.sender.Stop()
}
}
+120
View File
@@ -0,0 +1,120 @@
package logger
import (
"fmt"
"sync"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
// TelegramSender Telegram消息发送器(异步)
type TelegramSender struct {
bot *tgbotapi.BotAPI
chatID int64
msgChan chan string
retryCount int
retryInterval time.Duration
wg sync.WaitGroup
stopChan chan struct{}
once sync.Once
}
// NewTelegramSender 创建Telegram发送器(使用默认参数)
func NewTelegramSender(botToken string, chatID int64) (*TelegramSender, error) {
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, fmt.Errorf("创建telegram bot失败: %w", err)
}
// 设置为静默模式(不打印bot信息)
bot.Debug = false
sender := &TelegramSender{
bot: bot,
chatID: chatID,
msgChan: make(chan string, 20), // 固定缓冲区大小: 20
retryCount: 3, // 固定重试次数: 3
retryInterval: 3 * time.Second, // 固定重试间隔: 3秒
stopChan: make(chan struct{}),
}
// 启动异步发送协程
sender.Start()
return sender, nil
}
// Start 启动异步发送协程
func (s *TelegramSender) Start() {
s.wg.Add(1)
go s.listenAndSend()
}
// SendAsync 异步发送消息(非阻塞)
func (s *TelegramSender) SendAsync(message string) {
select {
case s.msgChan <- message:
// 成功写入缓冲区
default:
// 缓冲区满,丢弃消息(不阻塞主流程)
fmt.Printf("[Telegram] 消息缓冲区已满,消息被丢弃\n")
}
}
// listenAndSend 监听channel并发送消息
func (s *TelegramSender) listenAndSend() {
defer s.wg.Done()
for {
select {
case msg := <-s.msgChan:
s.sendWithRetry(msg)
case <-s.stopChan:
// 清空缓冲区后退出
for len(s.msgChan) > 0 {
msg := <-s.msgChan
s.sendWithRetry(msg)
}
return
}
}
}
// sendWithRetry 发送消息(带重试)
func (s *TelegramSender) sendWithRetry(message string) {
var err error
for i := 0; i < s.retryCount; i++ {
err = s.send(message)
if err == nil {
return // 发送成功
}
// 重试前等待
if i < s.retryCount-1 {
time.Sleep(s.retryInterval)
}
}
// 所有重试都失败
if err != nil {
fmt.Printf("[Telegram] 发送消息失败(已重试%d次): %v\n", s.retryCount, err)
}
}
// send 发送单条消息
func (s *TelegramSender) send(message string) error {
msg := tgbotapi.NewMessage(s.chatID, message)
msg.ParseMode = tgbotapi.ModeMarkdown
_, err := s.bot.Send(msg)
return err
}
// Stop 停止发送器(优雅关闭)
func (s *TelegramSender) Stop() {
s.once.Do(func() {
close(s.stopChan)
s.wg.Wait()
})
}
+47 -31
View File
@@ -25,54 +25,64 @@ type LeverageConfig struct {
// ConfigFile 配置文件结构,只包含需要同步到数据库的字段
type ConfigFile struct {
AdminMode bool `json:"admin_mode"`
BetaMode bool `json:"beta_mode"`
APIServerPort int `json:"api_server_port"`
UseDefaultCoins bool `json:"use_default_coins"`
DefaultCoins []string `json:"default_coins"`
CoinPoolAPIURL string `json:"coin_pool_api_url"`
OITopAPIURL string `json:"oi_top_api_url"`
MaxDailyLoss float64 `json:"max_daily_loss"`
MaxDrawdown float64 `json:"max_drawdown"`
StopTradingMinutes int `json:"stop_trading_minutes"`
Leverage LeverageConfig `json:"leverage"`
JWTSecret string `json:"jwt_secret"`
DataKLineTime string `json:"data_k_line_time"`
AdminMode bool `json:"admin_mode"`
BetaMode bool `json:"beta_mode"`
APIServerPort int `json:"api_server_port"`
UseDefaultCoins bool `json:"use_default_coins"`
DefaultCoins []string `json:"default_coins"`
CoinPoolAPIURL string `json:"coin_pool_api_url"`
OITopAPIURL string `json:"oi_top_api_url"`
MaxDailyLoss float64 `json:"max_daily_loss"`
MaxDrawdown float64 `json:"max_drawdown"`
StopTradingMinutes int `json:"stop_trading_minutes"`
Leverage LeverageConfig `json:"leverage"`
JWTSecret string `json:"jwt_secret"`
DataKLineTime string `json:"data_k_line_time"`
Log *config.LogConfig `json:"log"` // 日志配置
}
// syncConfigToDatabase 从config.json读取配置并同步到数据库
func syncConfigToDatabase(database *config.Database) error {
// loadConfigFile 读取并解析config.json文件
func loadConfigFile() (*ConfigFile, error) {
// 检查config.json是否存在
if _, err := os.Stat("config.json"); os.IsNotExist(err) {
log.Printf("📄 config.json不存在,跳过同步")
return nil
log.Printf("📄 config.json不存在,使用默认配置")
return &ConfigFile{}, nil
}
// 读取config.json
data, err := os.ReadFile("config.json")
if err != nil {
return fmt.Errorf("读取config.json失败: %w", err)
return nil, fmt.Errorf("读取config.json失败: %w", err)
}
// 解析JSON
var configFile ConfigFile
if err := json.Unmarshal(data, &configFile); err != nil {
return fmt.Errorf("解析config.json失败: %w", err)
return nil, fmt.Errorf("解析config.json失败: %w", err)
}
return &configFile, nil
}
// syncConfigToDatabase 将配置同步到数据库
func syncConfigToDatabase(database *config.Database, configFile *ConfigFile) error {
if configFile == nil {
return nil
}
log.Printf("🔄 开始同步config.json到数据库...")
// 同步各配置项到数据库
configs := map[string]string{
"admin_mode": fmt.Sprintf("%t", configFile.AdminMode),
"beta_mode": fmt.Sprintf("%t", configFile.BetaMode),
"api_server_port": strconv.Itoa(configFile.APIServerPort),
"use_default_coins": fmt.Sprintf("%t", configFile.UseDefaultCoins),
"coin_pool_api_url": configFile.CoinPoolAPIURL,
"oi_top_api_url": configFile.OITopAPIURL,
"max_daily_loss": fmt.Sprintf("%.1f", configFile.MaxDailyLoss),
"max_drawdown": fmt.Sprintf("%.1f", configFile.MaxDrawdown),
"stop_trading_minutes": strconv.Itoa(configFile.StopTradingMinutes),
"admin_mode": fmt.Sprintf("%t", configFile.AdminMode),
"beta_mode": fmt.Sprintf("%t", configFile.BetaMode),
"api_server_port": strconv.Itoa(configFile.APIServerPort),
"use_default_coins": fmt.Sprintf("%t", configFile.UseDefaultCoins),
"coin_pool_api_url": configFile.CoinPoolAPIURL,
"oi_top_api_url": configFile.OITopAPIURL,
"max_daily_loss": fmt.Sprintf("%.1f", configFile.MaxDailyLoss),
"max_drawdown": fmt.Sprintf("%.1f", configFile.MaxDrawdown),
"stop_trading_minutes": strconv.Itoa(configFile.StopTradingMinutes),
}
// 同步default_coins(转换为JSON字符串存储)
@@ -112,7 +122,7 @@ func syncConfigToDatabase(database *config.Database) error {
// loadBetaCodesToDatabase 加载内测码文件到数据库
func loadBetaCodesToDatabase(database *config.Database) error {
betaCodeFile := "beta_codes.txt"
// 检查内测码文件是否存在
if _, err := os.Stat(betaCodeFile); os.IsNotExist(err) {
log.Printf("📄 内测码文件 %s 不存在,跳过加载", betaCodeFile)
@@ -126,7 +136,7 @@ func loadBetaCodesToDatabase(database *config.Database) error {
}
log.Printf("🔄 发现内测码文件 %s (%.1f KB),开始加载...", betaCodeFile, float64(fileInfo.Size())/1024)
// 加载内测码到数据库
err = database.LoadBetaCodesFromFile(betaCodeFile)
if err != nil {
@@ -156,6 +166,12 @@ func main() {
dbPath = os.Args[1]
}
// 读取配置文件
configFile, err := loadConfigFile()
if err != nil {
log.Fatalf("❌ 读取config.json失败: %v", err)
}
log.Printf("📋 初始化配置数据库: %s", dbPath)
database, err := config.NewDatabase(dbPath)
if err != nil {
@@ -164,7 +180,7 @@ func main() {
defer database.Close()
// 同步config.json到数据库
if err := syncConfigToDatabase(database); err != nil {
if err := syncConfigToDatabase(database, configFile); err != nil {
log.Printf("⚠️ 同步config.json到数据库失败: %v", err)
}
+21 -20
View File
@@ -23,9 +23,9 @@ type CompetitionCache struct {
// TraderManager 管理多个trader实例
type TraderManager struct {
traders map[string]*trader.AutoTrader // key: trader ID
traders map[string]*trader.AutoTrader // key: trader ID
competitionCache *CompetitionCache
mu sync.RWMutex
mu sync.RWMutex
}
// NewTraderManager 创建trader管理器
@@ -506,19 +506,19 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) {
tm.competitionCache.mu.RUnlock()
tm.mu.RLock()
// 获取所有交易员列表
allTraders := make([]*trader.AutoTrader, 0, len(tm.traders))
for _, t := range tm.traders {
allTraders = append(allTraders, t)
}
tm.mu.RUnlock()
log.Printf("🔄 重新获取竞赛数据,交易员数量: %d", len(allTraders))
// 并发获取交易员数据
traders := tm.getConcurrentTraderData(allTraders)
// 按收益率排序(降序)
sort.Slice(traders, func(i, j int) bool {
pnlPctI, okI := traders[i]["total_pnl_pct"].(float64)
@@ -531,14 +531,14 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) {
}
return pnlPctI > pnlPctJ
})
// 限制返回前50名
totalCount := len(traders)
limit := 50
if len(traders) > limit {
traders = traders[:limit]
}
comparison := make(map[string]interface{})
comparison["traders"] = traders
comparison["count"] = len(traders)
@@ -559,21 +559,21 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
index int
data map[string]interface{}
}
// 创建结果通道
resultChan := make(chan traderResult, len(traders))
// 并发获取每个交易员的数据
for i, t := range traders {
go func(index int, trader *trader.AutoTrader) {
// 设置单个交易员的超时时间为3秒
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 使用通道来实现超时控制
accountChan := make(chan map[string]interface{}, 1)
errorChan := make(chan error, 1)
go func() {
account, err := trader.GetAccountInfo()
if err != nil {
@@ -582,10 +582,10 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
accountChan <- account
}
}()
status := trader.GetStatus()
var traderData map[string]interface{}
select {
case account := <-accountChan:
// 成功获取账户信息
@@ -634,18 +634,18 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
"error": "获取超时",
}
}
resultChan <- traderResult{index: index, data: traderData}
}(i, t)
}
// 收集所有结果
results := make([]map[string]interface{}, len(traders))
for i := 0; i < len(traders); i++ {
result := <-resultChan
results[result.index] = result.data
}
return results
}
@@ -656,20 +656,20 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) {
if err != nil {
return nil, err
}
// 从竞赛数据中提取前5名
allTraders, ok := competitionData["traders"].([]map[string]interface{})
if !ok {
return nil, fmt.Errorf("竞赛数据格式错误")
}
// 限制返回前5名
limit := 5
topTraders := allTraders
if len(allTraders) > limit {
topTraders = allTraders[:limit]
}
result := map[string]interface{}{
"traders": topTraders,
"count": len(topTraders),
@@ -889,6 +889,7 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode
DefaultCoins: defaultCoins,
TradingCoins: tradingCoins,
SystemPromptTemplate: traderCfg.SystemPromptTemplate, // 系统提示词模板
HyperliquidTestnet: exchangeCfg.Testnet, // Hyperliquid测试网
}
// 根据交易所类型设置API密钥
+24 -11
View File
@@ -121,19 +121,19 @@ func (m *WSMonitor) Start(coins []string) {
// 初始化交易对
err := m.Initialize(coins)
if err != nil {
log.Fatalf("❌ 初始化币种: %v", err)
log.Printf("❌ 初始化币种失败: %v", err)
return
}
err = m.combinedClient.Connect()
if err != nil {
log.Fatalf("❌ 批量订阅流: %v", err)
log.Printf("❌ 批量订阅流失败: %v", err)
return
}
// 订阅所有交易对
err = m.subscribeAll()
if err != nil {
log.Fatalf("❌ 订阅币种交易对: %v", err)
log.Printf("❌ 订阅币种交易对失败: %v", err)
return
}
}
@@ -159,7 +159,7 @@ func (m *WSMonitor) subscribeAll() error {
for _, st := range subKlineTime {
err := m.combinedClient.BatchSubscribeKlines(m.symbols, st)
if err != nil {
log.Fatalf("❌ 订阅3m K线: %v", err)
log.Printf("❌ 订阅 %s K线失败: %v", st, err)
return err
}
}
@@ -239,19 +239,32 @@ func (m *WSMonitor) GetCurrentKlines(symbol string, _time string) ([]Kline, erro
// 如果Ws数据未初始化完成时,单独使用api获取 - 兼容性代码 (防止在未初始化完成是,已经有交易员运行)
apiClient := NewAPIClient()
klines, err := apiClient.GetKlines(symbol, _time, 100)
m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines) //动态缓存进缓存
if err != nil {
return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err)
}
// 动态缓存进缓存
m.getKlineDataMap(_time).Store(strings.ToUpper(symbol), klines)
// 订阅 WebSocket 流
subStr := m.subscribeSymbol(symbol, _time)
subErr := m.combinedClient.subscribeStreams(subStr)
log.Printf("动态订阅流: %v", subStr)
if subErr != nil {
return nil, fmt.Errorf("动态订阅%v分钟K线失败: %v", _time, subErr)
log.Printf("警告: 动态订阅%v分钟K线失败: %v (使用API数据)", _time, subErr)
}
if err != nil {
return nil, fmt.Errorf("获取%v分钟K线失败: %v", _time, err)
}
return klines, fmt.Errorf("symbol不存在")
// ✅ FIX: 返回深拷贝而非引用
result := make([]Kline, len(klines))
copy(result, klines)
return result, nil
}
return value.([]Kline), nil
// ✅ FIX: 返回深拷贝而非引用,避免并发竞态条件
klines := value.([]Kline)
result := make([]Kline, len(klines))
copy(result, klines)
return result, nil
}
func (m *WSMonitor) Close() {
+23 -6
View File
@@ -7,6 +7,8 @@ import (
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
@@ -28,15 +30,28 @@ type Client struct {
Model string
Timeout time.Duration
UseFullURL bool // 是否使用完整URL(不添加/chat/completions
MaxTokens int // AI响应的最大token数
}
func New() *Client {
// 从环境变量读取 MaxTokens,默认 2000
maxTokens := 2000
if envMaxTokens := os.Getenv("AI_MAX_TOKENS"); envMaxTokens != "" {
if parsed, err := strconv.Atoi(envMaxTokens); err == nil && parsed > 0 {
maxTokens = parsed
log.Printf("🔧 [MCP] 使用环境变量 AI_MAX_TOKENS: %d", maxTokens)
} else {
log.Printf("⚠️ [MCP] 环境变量 AI_MAX_TOKENS 无效 (%s),使用默认值: %d", envMaxTokens, maxTokens)
}
}
// 默认配置
return &Client{
Provider: ProviderDeepSeek,
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Timeout: 120 * time.Second, // 增加到120秒,因为AI需要分析大量数据
Provider: ProviderDeepSeek,
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Timeout: 120 * time.Second, // 增加到120秒,因为AI需要分析大量数据
MaxTokens: maxTokens,
}
}
@@ -81,7 +96,7 @@ func (client *Client) SetQwenAPIKey(apiKey string, customURL string, customModel
client.Model = customModel
log.Printf("🔧 [MCP] Qwen 使用自定义 Model: %s", customModel)
} else {
client.Model = "qwen-plus" // 可选: qwen-turbo, qwen-plus, qwen-max
client.Model = "qwen3-max"
log.Printf("🔧 [MCP] Qwen 使用默认 Model: %s", client.Model)
}
// 打印 API Key 的前后各4位用于验证
@@ -190,7 +205,7 @@ func (client *Client) callOnce(systemPrompt, userPrompt string) (string, error)
"model": client.Model,
"messages": messages,
"temperature": 0.5, // 降低temperature以提高JSON格式稳定性
"max_tokens": 2000,
"max_tokens": client.MaxTokens,
}
// 注意:response_format 参数仅 OpenAI 支持,DeepSeek/Qwen 不支持
@@ -280,6 +295,8 @@ func isRetryableError(err error) bool {
"connection refused",
"temporary failure",
"no such host",
"stream error", // HTTP/2 stream 错误
"INTERNAL_ERROR", // 服务端内部错误
}
for _, retryable := range retryableErrors {
if strings.Contains(errStr, retryable) {
+180
View File
@@ -0,0 +1,180 @@
你是专业的加密货币AI,在合约市场进行自主交易。
# 核心目标
**最大化夏普比率(Sharpe Ratio**
夏普比率 = 平均收益 / 收益波动率
**这意味着**
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
- 稳定收益、控制回撤 → 提升夏普
- 耐心持仓、让利润奔跑 → 提升夏普
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
- 过度交易、手续费损耗 → 直接亏损
- 过早平仓、频繁进出 → 错失大行情
**关键认知**: 系统每3分钟扫描一次,但不意味着每次都要交易!
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
# 交易哲学 & 最佳实践
## 核心原则:
**资金保全第一**:保护资本比追求收益更重要 - 这是最高原则
**纪律胜于情绪**:严格执行退出策略,不随意移动止损或目标
**质量优于数量**:少量高信念交易胜过大量低信念交易
**适应波动性**:根据市场条件调整仓位大小和杠杆
**尊重趋势**:不要与强趋势作对,顺势而为
**风险控制优先**:每笔交易必须明确止损点和风险金额
## 稳健交易行为准则:
**等待最佳机会**:宁可错过10个普通机会,不错过1个优质机会
**分批止盈**:在关键阻力位分批获利了结
**严格止损**:入场前就设定好止损,绝不移动止损扩大风险
**仓位匹配**:根据信号强度调整仓位,不强求固定仓位
**情绪控制**:连续盈利不骄傲,连续亏损不报复
## 常见误区避免:
**过度交易**:频繁交易导致费用侵蚀利润
**复仇式交易**:亏损后立即加码试图"翻本"
**分析瘫痪**:过度等待完美信号,导致失机
**忽视相关性**:BTC常引领山寨币,须优先观察BTC趋势
**过度杠杆**:放大收益同时放大亏损
**逆势操作**:在强趋势中反向交易
# 交易频率认知
**量化标准**:
- 优秀交易员:每天2-4笔 = 每小时0.1-0.2笔
- 过度交易:每小时>2笔 = 严重问题
- 最佳节奏:开仓后持有至少30-60分钟
**稳健自查**:
- 如果你发现自己每个周期都在交易 → 说明标准太低
- 如果你发现持仓<30分钟就平仓 → 说明太急躁
- 如果连续3个周期没有合适机会 → 这是正常现象
- 如果感觉"必须交易" → 立即停止,这是危险信号
# 开仓标准(严格)
只在**强信号**时开仓,不确定就观望。
## 多维度信号验证:
**趋势确认**(必须满足):
- 4小时级别趋势明确
- 价格在关键EMA(20/50)之上/之下
- 至少2个时间框架方向一致
**技术指标**(至少满足3项):
- MACD方向与趋势一致
- RSI在合理区域(不做超买区做多/超卖区做空)
- 成交量配合价格方向
- 持仓量变化支持趋势
**入场时机**
- 回撤至支撑/阻力位
- 突破关键水平后回踩确认
- 形态完成(头肩、三角、旗形等)
**风险控制**
- 止损位置明确且合理
- 风险回报比 ≥ 1:3
- 单笔风险 ≤ 账户2%
## 避免开仓的情况:
横盘震荡,无明确方向
重大事件前后(不确定性高)
流动性不足时段
刚平仓不久(<15分钟)
情绪化状态(急于翻本或过度自信)
多个指标相互矛盾
# 夏普比率自我进化
每次你会收到**夏普比率**作为绩效反馈:
**夏普比率 < -0.5** (持续亏损):
→ **停止交易**,连续观望至少6个周期(18分钟)
→ **深度反思**
• 交易频率过高?(每小时>1次就是过度)
• 持仓时间过短?(<30分钟就是过早平仓)
• 信号强度不足?(信心度<80)
• 是否逆势操作?
• 止损执行是否严格?
**夏普比率 -0.5 ~ 0** (轻微亏损):
→ **严格控制**:只做信心度>85的交易
→ 减少交易频率:每小时最多1笔新开仓
→ 缩小仓位:使用正常仓位的50-70%
→ 耐心持仓:至少持有45分钟以上
**夏普比率 0 ~ 0.7** (正收益):
→ **维持策略**:按既定标准执行
→ 保持警惕:不因盈利而放松标准
**夏普比率 > 0.7** (优异表现):
→ **适度进取**:可在信心度>90时适度扩大仓位
→ 保持纪律:不因成功而改变稳健原则
# 决策流程
1. **分析账户状态**
- 当前夏普比率表现
- 保证金使用情况
- 持仓数量和状态
2. **评估市场环境**
- BTC整体趋势方向
- 市场波动率和情绪
- 重大事件风险
3. **检查现有持仓**
- 趋势是否持续?
- 是否需要调整止损/止盈?
- 是否达到目标位?
4. **寻找新机会**(仅在条件允许时):
- 多维度信号验证
- 风险回报比计算
- 仓位规模确定
5. **输出决策**:思维链分析 + 完整的JSON
# 风险控制框架
## 仓位管理:
- 单币种风险:≤ 账户净值的2%
- 总仓位风险:≤ 账户净值的6%
- 最大持仓:3个币种
- 杠杆使用:根据波动性调整,不追求最大杠杆
## 止损策略:
- 技术止损:基于支撑/阻力位
- 金额止损:单笔最大亏损金额
- 时间止损:持仓超过2小时无进展考虑离场
## 资金保护:
- 连续2笔亏损后:降低50%仓位
- 单日亏损超过5%:停止交易剩余时间
- 每周亏损超过10%:全面复盘策略
---
**记住**:
- 目标是夏普比率,不是交易频率
- 资金保全比利润追求更重要
- 宁可错过,不做低质量交易
- 风险回报比1:3是底线
- 纪律执行是长期盈利的关键
**现在,请基于以上原则分析市场并做出稳健决策**
+9
View File
@@ -100,6 +100,15 @@
---
# 动态止盈止损与部分平仓指引
- `partial_close` 用于锁定阶段性收益或降低风险,建议使用清晰比例(如 25% / 50% / 75%),并说明目的(例:"锁定关键阻力前利润""减半仓等待回踩确认")。
- 执行部分平仓后,应评估是否需要同步上调止损 / 下调止盈,确保剩余仓位符合新的风险回报结构。
- `update_stop_loss` / `update_take_profit` 优先用于顺势推进(如跟踪新高新低),避免在无新证据下放宽止损。
- 若计划分批退出,请在 `reasoning` 中描述剩余仓位的策略与失效条件,避免出现"减仓后不知道如何处理剩余部位"的情况。
---
# 决策流程(严格顺序)
## 第 0 步:疑惑检查
+194
View File
@@ -0,0 +1,194 @@
你是专业的加密货币交易AI,在合约市场进行自主交易。
# 核心目标
最大化夏普比率(Sharpe Ratio
夏普比率 = 平均收益 / 收益波动率
这意味着:
- 高质量交易(高胜率、大盈亏比)→ 提升夏普
- 稳定收益、控制回撤 → 提升夏普
- 耐心持仓、让利润奔跑 → 提升夏普
- 频繁交易、小盈小亏 → 增加波动,严重降低夏普
- 过度交易、手续费损耗 → 直接亏损
关键认知:系统每3分钟扫描一次,但不意味着每次都要交易!
大多数时候应该是 `wait` 或 `hold`,只在极佳机会时才开仓。
---
# 零号原则:疑惑优先
⚠️ 当你不确定时,默认选择 `wait`。
这是覆盖所有其他规则的最高优先级:
- 任何环节产生疑虑 → 立刻选择 `wait`
- 只有当信心 ≥80 且论据充分、条件完全满足时才允许开仓(✅ 从85降至80)
- 不确定是否违规 → 视同违规,直接 `wait`
---
# 基础交易约束
- 禁止对同一标的同时持有多空(NO hedging)
- 禁止在既有仓位上加码(NO pyramiding
- 允许使用 `partial_close` 锁定利润或降低风险
- 每笔交易必须预先设定止损与止盈,止损允许的账户亏损不超过 1-3%
- 确保预估清算价距离 ≥15%,避免被强平
---
# 仓位管理框架
## 杠杆选择指引
基于信心度的杠杆配置:
- 信心度 <80 → 不开仓(✅ 从85降至80)
- 信心度 80-85 → 杠杆 1-3x,风险预算 1.5%
- 信心度 85-92 → 杠杆 3-5x,风险预算 2%
- 信心度 >92 → 杠杆 5-8x(谨慎),风险预算 2.5%
---
# 决策流程(强制顺序)
1. **冷却期检查**
- 距离上一次开仓 ≥6 分钟(✅ 从9分钟降至6分钟)
- 若有持仓:持仓时间 ≥20 分钟(✅ 从30分钟降至20分钟)
- 止损出场后至少观望 6 分钟
→ 任意条件不满足 → `action = "wait"`
2. **夏普 / 连亏防御**
- 夏普 < -0.5 → 停手 6 个周期(18 分钟)
- 连续 2 次亏损 → 暂停 30 分钟(✅ 从45分钟降至30分钟)
- 连续 3 次亏损 → 暂停 12 小时(✅ 从24小时降至12小时)
- 连续 4 次亏损 → 暂停 48 小时(✅ 从72小时降至48小时)
3. **持仓管理优先**
- 若已有持仓:先评估是否需要平仓或调整止盈止损
4. **BTC 状态评估(若数据可用)**
- 标准模式:拥有 15m / 1h / 4h → 至少两条周期同向且无矛盾视为支持
- 简化模式:仅 15m / 4h → 同向视为支持
- 若完全缺少 BTC 数据 → 跳过此步,但开仓信心阈值上调至 85
5. **多周期趋势确认**(✅ 降低要求)
开仓前必须验证多周期趋势一致性:
**做多时检查**
- 检查 3m / 15m / 1h / 4h 的价格与 EMA20 关系
- 至少 2 个周期显示价格 > EMA20(✅ 从3个降至2个)
- 4h MACD ≥ -0.5(✅ 从-0.2放宽至-0.5
**做空时检查**
- 至少 2 个周期显示价格 < EMA20(✅ 从3个降至2个)
- 4h MACD ≤ +0.5(✅ 从+0.2放宽至+0.5
**趋势共振评分**
- 4 个周期全部同向 → 趋势极强(信心 +10)
- 3 个周期同向 → 趋势确认(信心 +5)
- 2 个周期同向 → 趋势可接受(允许开仓)
6. **新机会评估**
- 多空确认清单 ≥4/8 项通过(✅ 从5/8降至4/8)
- 风险回报比 ≥1:2.5(✅ 从1:3降至1:2.5
- 预计收益 > 手续费 ×3
- 清算距离 ≥15%
- 信心评分 ≥80(若跳过 BTC 检查则 ≥85)
---
# 多空确认清单(至少通过 4/8)(✅ 降低要求)
### 做多确认
| 指标 | 条件 |
|------|------|
| 15m MACD | >0(短期动能向上) |
| 价格 vs EMA20 | 价格高于 15m / 1h EMA20 |
| RSI | <45(超卖或温和超卖)(✅ 从30-40放宽至<45 |
| BuySellRatio | ≥0.55(✅ 从0.60降至0.55 |
| 成交量 | 近 20 根均量 ×1.3 以上(✅ 从1.5降至1.3 |
| BTC 状态* | 多头或中性 |
| 资金费率 | <0.02 或 -0.01~0.02 |
| 持仓量 OI 变化 | 近 4 小时上升 >+3%(✅ 从+5%降至+3% |
### 做空确认
| 指标 | 条件 |
|------|------|
| 15m MACD | <0(短期动能向下) |
| 价格 vs EMA20 | 价格低于 15m / 1h EMA20 |
| RSI | >60(超买或温和超买)(✅ 从65-70放宽至>60 |
| BuySellRatio | ≤0.45(✅ 从0.40提高至0.45 |
| 成交量 | 近 20 根均量 ×1.3 以上 |
| BTC 状态* | 空头或中性 |
| 资金费率 | >-0.02 或 -0.02~0.01 |
| 持仓量 OI 变化 | 近 4 小时上升 >+3% |
---
# 客观信心评分(基础分 60
1. **基础分:60**
2. **加分项(每项 +5,最高 100)**
- 多空确认清单 ≥4 项通过
- BTC 状态明确支持
- 多周期趋势共振(2 个周期同向 +3,3 个周期同向 +5,4 个周期全同向 +10)
- 15m / 1h / 4h MACD 同向
- 关键技术位明确(1h / 4h EMA、整数关口)
- 成交量放大(>1.3× 均量)
- 资金费率情绪背离
- 风险回报 ≥1:3
3. **减分项(每项 -10**
- 指标互相矛盾(MACD 与价格背离)
- BTC 状态不明仍计划大幅开仓
- 技术位不清晰或过近(<0.5%)
- 成交量萎缩(< 均量 ×0.7)
4. **阈值规则**
- <80 → 禁止开仓
- 80-85 → 风险预算 1.5%,杠杆 1-3x
- 85-92 → 风险预算 2%,杠杆 3-5x
- >92 → 风险预算 2.5%,杠杆 5-8x
---
# 最终检查清单(开仓前必须全部通过)
1. 冷却期合格(6分钟)
2. 夏普 / 连亏未触发停手
3. **多周期趋势确认通过(至少 2 个周期同向)**
4. BTC 状态明确支持(或缺失时已说明并提高阈值)
5. 多空确认清单 ≥4/8
6. 风险回报 ≥1:2.5
7. 预计收益 > 手续费 ×3
8. 清算距离 ≥15%
9. 客观信心评分 ≥80(缺 BTC 数据时 ≥85)
10. 失效条件已定义且写入 reasoning
任意一项未通过 → 立即选择 `wait`,并说明具体原因。
---
## 版本说明
**adaptive_relaxed v1.0 - 保守优化版**
核心调整:
1. ✅ 信心度阈值:85 → 80
2. ✅ 冷却期:9分钟 → 6分钟
3. ✅ 多周期趋势:3个同向 → 2个同向
4. ✅ 多空确认清单:5/8 → 4/8
5. ✅ RSI 放宽:30-40/65-70 → <45/>60
6. ✅ BuySellRatio 放宽:0.60/0.40 → 0.55/0.45
7. ✅ 成交量要求:1.5× → 1.3×
8. ✅ OI 变化:+5% → +3%
9. ✅ 风险回报比:1:3 → 1:2.5
预期效果:
- 交易频率增加 50-80%(一天 8-15 笔)
- 保持 50%+ 胜率
- 允许更多山寨币机会
- 保持核心風控(夏普、連虧停手)
+10
View File
@@ -165,6 +165,16 @@ start() {
# 读取环境变量
read_env_vars
# 确保必要的文件和目录存在(修复 Docker volume 挂载问题)
if [ ! -f "config.db" ]; then
print_info "创建数据库文件..."
touch config.db
fi
if [ ! -d "decision_logs" ]; then
print_info "创建日志目录..."
mkdir -p decision_logs
fi
# Auto-build frontend if missing or forced
# if [ ! -d "web/dist" ] || [ "$1" == "--build" ]; then
# build_frontend
+81 -2
View File
@@ -438,13 +438,23 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
return nil, err
}
// 🔍 调试:打印原始API响应
log.Printf("🔍 Aster API原始响应: %s", string(body))
// 查找USDT余额
totalBalance := 0.0
availableBalance := 0.0
crossUnPnl := 0.0
for _, bal := range balances {
// 🔍 调试:打印每条余额记录
log.Printf("🔍 余额记录: %+v", bal)
if asset, ok := bal["asset"].(string); ok && asset == "USDT" {
// 🔍 调试:打印USDT余额详情
log.Printf("🔍 USDT余额详情: balance=%v, availableBalance=%v, crossUnPnl=%v",
bal["balance"], bal["availableBalance"], bal["crossUnPnl"])
if wb, ok := bal["balance"].(string); ok {
totalBalance, _ = strconv.ParseFloat(wb, 64)
}
@@ -458,11 +468,25 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
}
}
// ✅ Aster API完全兼容Binance API格式
// balance字段 = wallet balance(不包含未实现盈亏)
// crossUnPnl = unrealized profit(未实现盈亏)
// crossWalletBalance = balance + crossUnPnl(全仓钱包余额,包含盈亏)
//
// 参考Binance官方文档:
// - Account Information V2: marginBalance = walletBalance + unrealizedProfit
// - Balance V3: crossWalletBalance = balance + crossUnPnl
log.Printf("✓ Aster API返回: 钱包余额=%.2f, 未实现盈亏=%.2f, 可用余额=%.2f",
totalBalance,
crossUnPnl,
availableBalance)
// 返回与Binance相同的字段名,确保AutoTrader能正确解析
return map[string]interface{}{
"totalWalletBalance": totalBalance,
"totalWalletBalance": totalBalance, // 钱包余额(不含未实现盈亏)
"availableBalance": availableBalance,
"totalUnrealizedProfit": crossUnPnl,
"totalUnrealizedProfit": crossUnPnl, // 未实现盈亏
}, nil
}
@@ -981,6 +1005,61 @@ func (t *AsterTrader) CancelAllOrders(symbol string) error {
return err
}
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
func (t *AsterTrader) CancelStopOrders(symbol string) error {
// 获取该币种的所有未完成订单
params := map[string]interface{}{
"symbol": symbol,
}
body, err := t.request("GET", "/fapi/v3/openOrders", params)
if err != nil {
return fmt.Errorf("获取未完成订单失败: %w", err)
}
var orders []map[string]interface{}
if err := json.Unmarshal(body, &orders); err != nil {
return fmt.Errorf("解析订单数据失败: %w", err)
}
// 过滤出止盈止损单并取消
canceledCount := 0
for _, order := range orders {
orderType, _ := order["type"].(string)
// 只取消止损和止盈订单
if orderType == "STOP_MARKET" ||
orderType == "TAKE_PROFIT_MARKET" ||
orderType == "STOP" ||
orderType == "TAKE_PROFIT" {
orderID, _ := order["orderId"].(float64)
cancelParams := map[string]interface{}{
"symbol": symbol,
"orderId": int64(orderID),
}
_, err := t.request("DELETE", "/fapi/v3/order", cancelParams)
if err != nil {
log.Printf(" ⚠ 取消订单 %d 失败: %v", int64(orderID), err)
continue
}
canceledCount++
log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)",
symbol, int64(orderID), orderType)
}
}
if canceledCount == 0 {
log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol)
} else {
log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount)
}
return nil
}
// FormatQuantity 格式化数量(实现Trader接口)
func (t *AsterTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
formatted, err := t.formatQuantity(symbol, quantity)
+221 -11
View File
@@ -199,7 +199,8 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string)
// 设置默认系统提示词模板
systemPromptTemplate := config.SystemPromptTemplate
if systemPromptTemplate == "" {
systemPromptTemplate = "default" // 默认使用 default 模板
// feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损)
systemPromptTemplate = "adaptive"
}
return &AutoTrader{
@@ -359,9 +360,9 @@ func (at *AutoTrader) autoSyncBalanceIfNeeded() {
func (at *AutoTrader) runCycle() error {
at.callCount++
log.Printf("\n" + strings.Repeat("=", 70))
log.Print("\n" + strings.Repeat("=", 70) + "\n")
log.Printf("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
log.Printf(strings.Repeat("=", 70))
log.Println(strings.Repeat("=", 70))
// 创建决策记录
record := &logger.DecisionRecord{
@@ -451,19 +452,19 @@ func (at *AutoTrader) runCycle() error {
// 打印系统提示词和AI思维链(即使有错误,也要输出以便调试)
if decision != nil {
if decision.SystemPrompt != "" {
log.Printf("\n" + strings.Repeat("=", 70))
log.Print("\n" + strings.Repeat("=", 70) + "\n")
log.Printf("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate)
log.Println(strings.Repeat("=", 70))
log.Println(decision.SystemPrompt)
log.Printf(strings.Repeat("=", 70) + "\n")
log.Println(strings.Repeat("=", 70))
}
if decision.CoTTrace != "" {
log.Printf("\n" + strings.Repeat("-", 70))
log.Print("\n" + strings.Repeat("-", 70) + "\n")
log.Println("💭 AI思维链分析(错误情况):")
log.Println(strings.Repeat("-", 70))
log.Println(decision.CoTTrace)
log.Printf(strings.Repeat("-", 70) + "\n")
log.Println(strings.Repeat("-", 70))
}
}
@@ -586,6 +587,12 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
if quantity < 0 {
quantity = -quantity // 空仓数量为负,转为正数
}
// 跳过已平仓的持仓(quantity = 0),防止"幽灵持仓"传递给AI
if quantity == 0 {
continue
}
unrealizedPnl := pos["unRealizedProfit"].(float64)
liquidationPrice := pos["liquidationPrice"].(float64)
@@ -698,6 +705,12 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act
return at.executeCloseLongWithRecord(decision, actionRecord)
case "close_short":
return at.executeCloseShortWithRecord(decision, actionRecord)
case "update_stop_loss":
return at.executeUpdateStopLossWithRecord(decision, actionRecord)
case "update_take_profit":
return at.executeUpdateTakeProfitWithRecord(decision, actionRecord)
case "partial_close":
return at.executePartialCloseWithRecord(decision, actionRecord)
case "hold", "wait":
// 无需执行,仅记录
return nil
@@ -876,6 +889,201 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a
return nil
}
// executeUpdateStopLossWithRecord 执行调整止损并记录详细信息
func (at *AutoTrader) executeUpdateStopLossWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
log.Printf(" 🎯 调整止损: %s → %.2f", decision.Symbol, decision.NewStopLoss)
// 获取当前价格
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// 获取当前持仓
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("获取持仓失败: %w", err)
}
// 查找目标持仓
var targetPosition map[string]interface{}
for _, pos := range positions {
symbol, _ := pos["symbol"].(string)
posAmt, _ := pos["positionAmt"].(float64)
if symbol == decision.Symbol && posAmt != 0 {
targetPosition = pos
break
}
}
if targetPosition == nil {
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
}
// 获取持仓方向和数量
side, _ := targetPosition["side"].(string)
positionSide := strings.ToUpper(side)
positionAmt, _ := targetPosition["positionAmt"].(float64)
// 验证新止损价格合理性
if positionSide == "LONG" && decision.NewStopLoss >= marketData.CurrentPrice {
return fmt.Errorf("多单止损必须低于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
}
if positionSide == "SHORT" && decision.NewStopLoss <= marketData.CurrentPrice {
return fmt.Errorf("空单止损必须高于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss)
}
// 取消旧的止损单(避免多个止损单共存)
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
log.Printf(" ⚠ 取消旧止损单失败: %v", err)
// 不中断执行,继续设置新止损
}
// 调用交易所 API 修改止损
quantity := math.Abs(positionAmt)
err = at.trader.SetStopLoss(decision.Symbol, positionSide, quantity, decision.NewStopLoss)
if err != nil {
return fmt.Errorf("修改止损失败: %w", err)
}
log.Printf(" ✓ 止损已调整: %.2f (当前价格: %.2f)", decision.NewStopLoss, marketData.CurrentPrice)
return nil
}
// executeUpdateTakeProfitWithRecord 执行调整止盈并记录详细信息
func (at *AutoTrader) executeUpdateTakeProfitWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
log.Printf(" 🎯 调整止盈: %s → %.2f", decision.Symbol, decision.NewTakeProfit)
// 获取当前价格
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// 获取当前持仓
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("获取持仓失败: %w", err)
}
// 查找目标持仓
var targetPosition map[string]interface{}
for _, pos := range positions {
symbol, _ := pos["symbol"].(string)
posAmt, _ := pos["positionAmt"].(float64)
if symbol == decision.Symbol && posAmt != 0 {
targetPosition = pos
break
}
}
if targetPosition == nil {
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
}
// 获取持仓方向和数量
side, _ := targetPosition["side"].(string)
positionSide := strings.ToUpper(side)
positionAmt, _ := targetPosition["positionAmt"].(float64)
// 验证新止盈价格合理性
if positionSide == "LONG" && decision.NewTakeProfit <= marketData.CurrentPrice {
return fmt.Errorf("多单止盈必须高于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
}
if positionSide == "SHORT" && decision.NewTakeProfit >= marketData.CurrentPrice {
return fmt.Errorf("空单止盈必须低于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit)
}
// 取消旧的止盈单(避免多个止盈单共存)
if err := at.trader.CancelStopOrders(decision.Symbol); err != nil {
log.Printf(" ⚠ 取消旧止盈单失败: %v", err)
// 不中断执行,继续设置新止盈
}
// 调用交易所 API 修改止盈
quantity := math.Abs(positionAmt)
err = at.trader.SetTakeProfit(decision.Symbol, positionSide, quantity, decision.NewTakeProfit)
if err != nil {
return fmt.Errorf("修改止盈失败: %w", err)
}
log.Printf(" ✓ 止盈已调整: %.2f (当前价格: %.2f)", decision.NewTakeProfit, marketData.CurrentPrice)
return nil
}
// executePartialCloseWithRecord 执行部分平仓并记录详细信息
func (at *AutoTrader) executePartialCloseWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error {
log.Printf(" 📊 部分平仓: %s %.1f%%", decision.Symbol, decision.ClosePercentage)
// 验证百分比范围
if decision.ClosePercentage <= 0 || decision.ClosePercentage > 100 {
return fmt.Errorf("平仓百分比必须在 0-100 之间,当前: %.1f", decision.ClosePercentage)
}
// 获取当前价格
marketData, err := market.Get(decision.Symbol)
if err != nil {
return err
}
actionRecord.Price = marketData.CurrentPrice
// 获取当前持仓
positions, err := at.trader.GetPositions()
if err != nil {
return fmt.Errorf("获取持仓失败: %w", err)
}
// 查找目标持仓
var targetPosition map[string]interface{}
for _, pos := range positions {
symbol, _ := pos["symbol"].(string)
posAmt, _ := pos["positionAmt"].(float64)
if symbol == decision.Symbol && posAmt != 0 {
targetPosition = pos
break
}
}
if targetPosition == nil {
return fmt.Errorf("持仓不存在: %s", decision.Symbol)
}
// 获取持仓方向和数量
side, _ := targetPosition["side"].(string)
positionSide := strings.ToUpper(side)
positionAmt, _ := targetPosition["positionAmt"].(float64)
// 计算平仓数量
totalQuantity := math.Abs(positionAmt)
closeQuantity := totalQuantity * (decision.ClosePercentage / 100.0)
actionRecord.Quantity = closeQuantity
// 执行平仓
var order map[string]interface{}
if positionSide == "LONG" {
order, err = at.trader.CloseLong(decision.Symbol, closeQuantity)
} else {
order, err = at.trader.CloseShort(decision.Symbol, closeQuantity)
}
if err != nil {
return fmt.Errorf("部分平仓失败: %w", err)
}
// 记录订单ID
if orderID, ok := order["orderId"].(int64); ok {
actionRecord.OrderID = orderID
}
remainingQuantity := totalQuantity - closeQuantity
log.Printf(" ✓ 部分平仓成功: 平仓 %.4f (%.1f%%), 剩余 %.4f",
closeQuantity, decision.ClosePercentage, remainingQuantity)
return nil
}
// GetID 获取trader ID
func (at *AutoTrader) GetID() string {
return at.id
@@ -1089,12 +1297,14 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision
// 定义优先级
getActionPriority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1 // 最高优先级:先平仓
case "close_long", "close_short", "partial_close":
return 1 // 最高优先级:先平仓(包括部分平仓)
case "update_stop_loss", "update_take_profit":
return 2 // 调整持仓止盈止损
case "open_long", "open_short":
return 2 // 次优先级:后开仓
return 3 // 次优先级:后开仓
case "hold", "wait":
return 3 // 最低优先级:观望
return 4 // 最低优先级:观望
default:
return 999 // 未知动作放最后
}
+47
View File
@@ -425,6 +425,53 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
return nil
}
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
// 获取该币种的所有未完成订单
orders, err := t.client.NewListOpenOrdersService().
Symbol(symbol).
Do(context.Background())
if err != nil {
return fmt.Errorf("获取未完成订单失败: %w", err)
}
// 过滤出止盈止损单并取消
canceledCount := 0
for _, order := range orders {
orderType := order.Type
// 只取消止损和止盈订单
if orderType == futures.OrderTypeStopMarket ||
orderType == futures.OrderTypeTakeProfitMarket ||
orderType == futures.OrderTypeStop ||
orderType == futures.OrderTypeTakeProfit {
_, err := t.client.NewCancelOrderService().
Symbol(symbol).
OrderID(order.OrderID).
Do(context.Background())
if err != nil {
log.Printf(" ⚠ 取消订单 %d 失败: %v", order.OrderID, err)
continue
}
canceledCount++
log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)",
symbol, order.OrderID, orderType)
}
}
if canceledCount == 0 {
log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol)
} else {
log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount)
}
return nil
}
// GetMarketPrice 获取市场价格
func (t *FuturesTrader) GetMarketPrice(symbol string) (float64, error) {
prices, err := t.client.NewListPricesService().Symbol(symbol).Do(context.Background())
+128 -22
View File
@@ -2,10 +2,12 @@ package trader
import (
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/crypto"
"github.com/sonirico/go-hyperliquid"
@@ -22,6 +24,9 @@ type HyperliquidTrader struct {
// NewHyperliquidTrader 创建Hyperliquid交易器
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) {
// 去掉私钥的 0x 前缀(如果有,不区分大小写)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
// 解析私钥
privateKey, err := crypto.HexToECDSA(privateKeyHex)
if err != nil {
@@ -34,13 +39,18 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
apiURL = hyperliquid.TestnetAPIURL
}
// // 从私钥生成钱包地址
// pubKey := privateKey.Public()
// publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
// if !ok {
// return nil, fmt.Errorf("无法转换公钥")
// }
// walletAddr := crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
// 从私钥生成钱包地址(如果未提供)
if walletAddr == "" {
pubKey := privateKey.Public()
publicKeyECDSA, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("无法转换公钥")
}
walletAddr = crypto.PubkeyToAddress(*publicKeyECDSA).Hex()
log.Printf("✓ 从私钥自动生成钱包地址: %s", walletAddr)
} else {
log.Printf("✓ 使用提供的钱包地址: %s", walletAddr)
}
ctx := context.Background()
@@ -76,23 +86,54 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
log.Printf("🔄 正在调用Hyperliquid API获取账户余额...")
// 获取账户状态
// ✅ Step 1: 查询 Spot 现货账户余额
spotState, err := t.exchange.Info().SpotUserState(t.ctx, t.walletAddr)
var spotUSDCBalance float64 = 0.0
if err != nil {
log.Printf("⚠️ 查询 Spot 余额失败(可能无现货资产): %v", err)
} else if spotState != nil && len(spotState.Balances) > 0 {
for _, balance := range spotState.Balances {
if balance.Coin == "USDC" {
spotUSDCBalance, _ = strconv.ParseFloat(balance.Total, 64)
log.Printf("✓ 发现 Spot 现货余额: %.2f USDC", spotUSDCBalance)
break
}
}
}
// ✅ Step 2: 查询 Perpetuals 合约账户状态
accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr)
if err != nil {
log.Printf("❌ Hyperliquid API调用失败: %v", err)
log.Printf("❌ Hyperliquid Perpetuals API调用失败: %v", err)
return nil, fmt.Errorf("获取账户信息失败: %w", err)
}
// 解析余额信息(MarginSummary字段都是string
result := make(map[string]interface{})
// 🔍 调试:打印API返回的完整CrossMarginSummary结构
summaryJSON, _ := json.MarshalIndent(accountState.MarginSummary, " ", " ")
log.Printf("🔍 [DEBUG] Hyperliquid API CrossMarginSummary完整数据:")
log.Printf("%s", string(summaryJSON))
// ✅ Step 3: 根据保证金模式动态选择正确的摘要(CrossMarginSummary 或 MarginSummary
var accountValue, totalMarginUsed float64
var summaryType string
var summary interface{}
accountValue, _ := strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
totalMarginUsed, _ := strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
if t.isCrossMargin {
// 全仓模式:使用 CrossMarginSummary
accountValue, _ = strconv.ParseFloat(accountState.CrossMarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.CrossMarginSummary.TotalMarginUsed, 64)
summaryType = "CrossMarginSummary (全仓)"
summary = accountState.CrossMarginSummary
} else {
// 逐仓模式:使用 MarginSummary
accountValue, _ = strconv.ParseFloat(accountState.MarginSummary.AccountValue, 64)
totalMarginUsed, _ = strconv.ParseFloat(accountState.MarginSummary.TotalMarginUsed, 64)
summaryType = "MarginSummary (逐仓)"
summary = accountState.MarginSummary
}
// 🔍 调试:打印API返回的完整摘要结构
summaryJSON, _ := json.MarshalIndent(summary, " ", " ")
log.Printf("🔍 [DEBUG] Hyperliquid API %s 完整数据:", summaryType)
log.Printf("%s", string(summaryJSON))
// ⚠️ 关键修复:从所有持仓中累加真正的未实现盈亏
totalUnrealizedPnl := 0.0
@@ -109,16 +150,47 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// 需要返回"不包含未实现盈亏的钱包余额"
walletBalanceWithoutUnrealized := accountValue - totalUnrealizedPnl
result["totalWalletBalance"] = walletBalanceWithoutUnrealized // 钱包余额(不含未实现盈亏
result["availableBalance"] = accountValue - totalMarginUsed // 可用余额(总净值 - 占用保证金)
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未实现盈亏
// ✅ Step 4: 使用 Withdrawable 欄位(PR #443
// Withdrawable 是官方提供的真实可提现余额,比简单计算更可靠
availableBalance := 0.0
if accountState.Withdrawable != "" {
withdrawable, err := strconv.ParseFloat(accountState.Withdrawable, 64)
if err == nil && withdrawable > 0 {
availableBalance = withdrawable
log.Printf("✓ 使用 Withdrawable 作为可用余额: %.2f", availableBalance)
}
}
log.Printf("✓ Hyperliquid 账户: 总净值=%.2f (钱包%.2f+未实现%.2f), 可用=%.2f, 保证金占用=%.2f",
// 降级方案:如果没有 Withdrawable,使用简单计算
if availableBalance == 0 && accountState.Withdrawable == "" {
availableBalance = accountValue - totalMarginUsed
if availableBalance < 0 {
log.Printf("⚠️ 计算出的可用余额为负数 (%.2f),重置为 0", availableBalance)
availableBalance = 0
}
}
// ✅ Step 5: 正確處理 Spot + Perpetuals 余额
// 重要:Spot 只加到總資產,不加到可用餘額
// 原因:Spot 和 Perpetuals 是獨立帳戶,需手動 ClassTransfer 才能轉帳
totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance
result["totalWalletBalance"] = totalWalletBalance // 總資產(Perp + Spot
result["availableBalance"] = availableBalance // 可用餘額(僅 Perpetuals,不含 Spot
result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未實現盈虧(僅來自 Perpetuals)
result["spotBalance"] = spotUSDCBalance // Spot 現貨餘額(單獨返回)
log.Printf("✓ Hyperliquid 完整账户:")
log.Printf(" • Spot 现货余额: %.2f USDC (需手动转账到 Perpetuals 才能开仓)", spotUSDCBalance)
log.Printf(" • Perpetuals 合约净值: %.2f USDC (钱包%.2f + 未实现%.2f)",
accountValue,
walletBalanceWithoutUnrealized,
totalUnrealizedPnl,
result["availableBalance"],
totalMarginUsed)
totalUnrealizedPnl)
log.Printf(" • Perpetuals 可用余额: %.2f USDC (可直接用於開倉)", availableBalance)
log.Printf(" • 保证金占用: %.2f USDC", totalMarginUsed)
log.Printf(" • 總資產 (Perp+Spot): %.2f USDC", totalWalletBalance)
log.Printf(" ⭐ 总资产: %.2f USDC | Perp 可用: %.2f USDC | Spot 余额: %.2f USDC",
totalWalletBalance, availableBalance, spotUSDCBalance)
return result, nil
}
@@ -501,6 +573,40 @@ func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
return nil
}
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
coin := convertSymbolToHyperliquid(symbol)
// 获取所有挂单
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
if err != nil {
return fmt.Errorf("获取挂单失败: %w", err)
}
// 注意:Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段
// 因此暂时取消该币种的所有挂单(包括止盈止损单)
// 这是安全的,因为在设置新的止盈止损之前,应该清理所有旧订单
canceledCount := 0
for _, order := range openOrders {
if order.Coin == coin {
_, err := t.exchange.Cancel(t.ctx, coin, order.Oid)
if err != nil {
log.Printf(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err)
continue
}
canceledCount++
}
}
if canceledCount == 0 {
log.Printf(" %s 没有挂单需要取消", symbol)
} else {
log.Printf(" ✓ 已取消 %s 的 %d 个挂单(包括止盈/止损单)", symbol, canceledCount)
}
return nil
}
// GetMarketPrice 获取市场价格
func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
coin := convertSymbolToHyperliquid(symbol)
+3
View File
@@ -39,6 +39,9 @@ type Trader interface {
// CancelAllOrders 取消该币种的所有挂单
CancelAllOrders(symbol string) error
// CancelStopOrders 取消该币种的止盈/止损单(用于调整止盈止损位置)
CancelStopOrders(symbol string) error
// FormatQuantity 格式化数量到正确的精度
FormatQuantity(symbol string, quantity float64) (string, error)
}
+1
View File
@@ -0,0 +1 @@
npm test
+22
View File
@@ -0,0 +1,22 @@
# Dependencies
node_modules
# Build outputs
dist
build
*.tsbuildinfo
# Config files
pnpm-lock.yaml
package-lock.json
yarn.lock
# Logs
*.log
# Coverage
coverage
# IDE
.vscode
.idea
+13
View File
@@ -0,0 +1,13 @@
{
"semi": false,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"arrowParens": "always",
"bracketSpacing": true,
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}
+89
View File
@@ -0,0 +1,89 @@
import js from '@eslint/js'
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import prettier from 'eslint-plugin-prettier'
export default [
{
ignores: ['dist', 'node_modules', 'build', '*.config.js']
},
js.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true
}
},
globals: {
window: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly'
}
},
plugins: {
'@typescript-eslint': tseslint,
'react': react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'prettier': prettier
},
rules: {
...tseslint.configs.recommended.rules,
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
// Prettier integration
'prettier/prettier': 'error',
// React rules
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
// 该规则在 TS 项目中经常与 TS 的类型检查重复,关闭以避免误报
'no-undef': 'off',
// TypeScript rules
// 放宽以下规则以避免在不改变功能的情况下大面积改动代码
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// React Refresh
'react-refresh/only-export-components': 'off',
// General rules
'no-console': 'off',
'no-debugger': 'off',
// 新版 react-hooks 推荐规则在本项目会造成大量误报,关闭以免影响开发体验
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/preserve-manual-memoization': 'off',
// 某些字符串中包含未转义字符用于展示,关闭以避免不必要的修改
'react/no-unescaped-entities': 'off',
// 可视情况关闭依赖数组校验(如需严格可改为 'warn'
'react-hooks/exhaustive-deps': 'off'
},
settings: {
react: {
version: 'detect'
}
}
}
]
+3716
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -5,7 +5,12 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"prepare": "husky"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
@@ -22,13 +27,34 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"postcss": "^8.4.49",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^6.0.7"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,json}": [
"prettier --write"
]
}
}
+569 -283
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+300 -190
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo } from 'react'
import {
LineChart,
Line,
@@ -9,129 +9,140 @@ import {
ResponsiveContainer,
ReferenceLine,
Legend,
} from 'recharts';
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionTraderData } from '../types';
import { getTraderColor } from '../utils/traderColors';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { BarChart3 } from 'lucide-react';
} from 'recharts'
import useSWR from 'swr'
import { api } from '../lib/api'
import type { CompetitionTraderData } from '../types'
import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { BarChart3 } from 'lucide-react'
interface ComparisonChartProps {
traders: CompetitionTraderData[];
traders: CompetitionTraderData[]
}
export function ComparisonChart({ traders }: ComparisonChartProps) {
const { language } = useLanguage();
const { language } = useLanguage()
// 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
// 生成唯一的key,当traders变化时会触发重新请求
const tradersKey = traders.map(t => t.trader_id).sort().join(',');
const tradersKey = traders
.map((t) => t.trader_id)
.sort()
.join(',')
const { data: allTraderHistories, isLoading } = useSWR(
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
async () => {
// 使用批量API一次性获取所有trader的历史数据
const traderIds = traders.map(trader => trader.trader_id);
const batchData = await api.getEquityHistoryBatch(traderIds);
const traderIds = traders.map((trader) => trader.trader_id)
const batchData = await api.getEquityHistoryBatch(traderIds)
// 转换为原格式,保持与原有代码兼容
return traders.map(trader => {
return batchData.histories[trader.trader_id] || [];
});
return traders.map((trader) => {
return batchData.histories[trader.trader_id] || []
})
},
{
refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
revalidateOnFocus: false,
dedupingInterval: 20000,
}
);
)
// 将数据转换为与原格式兼容的结构
const traderHistories = useMemo(() => {
if (!allTraderHistories) {
return traders.map(() => ({ data: undefined }));
return traders.map(() => ({ data: undefined }))
}
return allTraderHistories.map(data => ({ data }));
}, [allTraderHistories, traders.length]);
return allTraderHistories.map((data) => ({ data }))
}, [allTraderHistories, traders.length])
// 使用useMemo自动处理数据合并,直接使用data对象作为依赖
const combinedData = useMemo(() => {
// 等待所有数据加载完成
const allLoaded = traderHistories.every((h) => h.data);
if (!allLoaded) return [];
const allLoaded = traderHistories.every((h) => h.data)
if (!allLoaded) return []
console.log(`[${new Date().toISOString()}] Recalculating chart data...`);
console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
// 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
// 收集所有时间戳
const timestampMap = new Map<string, {
timestamp: string;
time: string;
traders: Map<string, { pnl_pct: number; equity: number }>;
}>();
const timestampMap = new Map<
string,
{
timestamp: string
time: string
traders: Map<string, { pnl_pct: number; equity: number }>
}
>()
traderHistories.forEach((history, index) => {
const trader = traders[index];
if (!history.data) return;
const trader = traders[index]
if (!history.data) return
console.log(`Trader ${trader.trader_id}: ${history.data.length} data points`);
console.log(
`Trader ${trader.trader_id}: ${history.data.length} data points`
)
history.data.forEach((point: any) => {
const ts = point.timestamp;
const ts = point.timestamp
if (!timestampMap.has(ts)) {
const time = new Date(ts).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
})
timestampMap.set(ts, {
timestamp: ts,
time,
traders: new Map()
});
traders: new Map(),
})
}
// 计算盈亏百分比:从total_pnl和balance计算
// 假设初始余额 = balance - total_pnl
const initialBalance = point.balance - point.total_pnl;
const pnlPct = initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0;
const initialBalance = point.balance - point.total_pnl
const pnlPct =
initialBalance > 0 ? (point.total_pnl / initialBalance) * 100 : 0
timestampMap.get(ts)!.traders.set(trader.trader_id, {
pnl_pct: pnlPct,
equity: point.total_equity
});
});
});
equity: point.total_equity,
})
})
})
// 按时间戳排序,转换为数组
const combined = Array.from(timestampMap.entries())
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
.map(([ts, data], index) => {
const entry: any = {
index: index + 1, // 使用序号代替cycle
index: index + 1, // 使用序号代替cycle
time: data.time,
timestamp: ts
};
timestamp: ts,
}
traders.forEach((trader) => {
const traderData = data.traders.get(trader.trader_id);
const traderData = data.traders.get(trader.trader_id)
if (traderData) {
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct;
entry[`${trader.trader_id}_equity`] = traderData.equity;
entry[`${trader.trader_id}_pnl_pct`] = traderData.pnl_pct
entry[`${trader.trader_id}_equity`] = traderData.equity
}
});
})
return entry;
});
return entry
})
if (combined.length > 0) {
const lastPoint = combined[combined.length - 1];
console.log(`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`);
const lastPoint = combined[combined.length - 1]
console.log(
`Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`
)
}
return combined;
}, [allTraderHistories, traders]);
return combined
}, [allTraderHistories, traders])
if (isLoading) {
return (
@@ -139,67 +150,69 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
<div className="spinner mx-auto mb-4"></div>
<div className="text-sm font-semibold">Loading comparison data...</div>
</div>
);
)
}
if (combinedData.length === 0) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<BarChart3 className="w-12 h-12 mx-auto mb-4 opacity-60" />
<div className="text-lg font-semibold mb-2">{t('noHistoricalData', language)}</div>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
);
)
}
// 限制显示数据点
const MAX_DISPLAY_POINTS = 2000;
const MAX_DISPLAY_POINTS = 2000
const displayData =
combinedData.length > MAX_DISPLAY_POINTS
? combinedData.slice(-MAX_DISPLAY_POINTS)
: combinedData;
: combinedData
// 计算Y轴范围
const calculateYDomain = () => {
const allValues: number[] = [];
const allValues: number[] = []
displayData.forEach((point) => {
traders.forEach((trader) => {
const value = point[`${trader.trader_id}_pnl_pct`];
const value = point[`${trader.trader_id}_pnl_pct`]
if (value !== undefined) {
allValues.push(value);
allValues.push(value)
}
});
});
})
})
if (allValues.length === 0) return [-5, 5];
if (allValues.length === 0) return [-5, 5]
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
const minVal = Math.min(...allValues)
const maxVal = Math.max(...allValues)
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
return [
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
};
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
}
// 使用统一的颜色分配逻辑(与Leaderboard保持一致)
const traderColor = (traderId: string) => getTraderColor(traders, traderId);
const traderColor = (traderId: string) => getTraderColor(traders, traderId)
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const data = payload[0].payload
return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div
className="rounded p-3 shadow-xl"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{data.time} - #{data.index}
</div>
{traders.map((trader) => {
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
const equity = data[`${trader.trader_id}_equity`];
if (pnlPct === undefined) return null;
const pnlPct = data[`${trader.trader_id}_pnl_pct`]
const equity = data[`${trader.trader_id}_equity`]
if (pnlPct === undefined) return null
return (
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
@@ -209,33 +222,51 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
>
{trader.trader_name}
</div>
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
<div
className="text-sm mono font-bold"
style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}
>
{pnlPct >= 0 ? '+' : ''}
{pnlPct.toFixed(2)}%
<span
className="text-xs ml-2 font-normal"
style={{ color: '#848E9C' }}
>
({equity?.toFixed(2)} USDT)
</span>
</div>
</div>
);
)
})}
</div>
);
)
}
return null;
};
return null
}
// 计算当前差距
const currentGap = displayData.length > 0 ? (() => {
const lastPoint = displayData[displayData.length - 1];
const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0);
return Math.abs(values[0] - values[1]);
})() : 0;
const currentGap =
displayData.length > 0
? (() => {
const lastPoint = displayData[displayData.length - 1]
const values = traders.map(
(t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
)
return Math.abs(values[0] - values[1])
})()
: 0
return (
<div>
<div style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<div
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
<div
style={{
position: 'absolute',
top: '20px',
@@ -245,116 +276,195 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width="100%" height={520}>
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.9} />
<stop offset="95%" stopColor={traderColor(trader.trader_id)} stopOpacity={0.2} />
</linearGradient>
))}
</defs>
<LineChart
data={displayData}
margin={{ top: 20, right: 30, left: 20, bottom: 40 }}
>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={traderColor(trader.trader_id)}
stopOpacity={0.9}
/>
<stop
offset="95%"
stopColor={traderColor(trader.trader_id)}
stopOpacity={0.2}
/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={traderColor(trader.trader_id)}
strokeWidth={3}
dot={displayData.length < 50 ? { fill: traderColor(trader.trader_id), r: 3 } : false}
activeDot={{ r: 6, fill: traderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
name={trader.trader_name}
connectNulls
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
const trader = traders.find((t) => t.trader_id === traderId);
return (
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
);
}}
/>
</LineChart>
</ResponsiveContainer>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={traderColor(trader.trader_id)}
strokeWidth={3}
dot={
displayData.length < 50
? { fill: traderColor(trader.trader_id), r: 3 }
: false
}
activeDot={{
r: 6,
fill: traderColor(trader.trader_id),
stroke: '#fff',
strokeWidth: 2,
}}
name={trader.trader_name}
connectNulls
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find(
(t) => value === t.trader_name
)?.trader_id
const trader = traders.find((t) => t.trader_id === traderId)
return (
<span
style={{
color: entry.color,
fontWeight: 600,
fontSize: '14px',
}}
>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
)
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('comparisonMode', language)}</div>
<div className="text-sm md:text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
<div
className="mt-6 grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 pt-5"
style={{ borderTop: '1px solid #2B3139' }}
>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('comparisonMode', language)}
</div>
<div
className="text-sm md:text-base font-bold"
style={{ color: '#EAECEF' }}
>
PnL %
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('dataPoints', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>{t('count', language, {count: combinedData.length})}</div>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('dataPoints', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{t('count', language, { count: combinedData.length })}
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('currentGap', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('currentGap', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}
>
{currentGap.toFixed(2)}%
</div>
</div>
<div className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}>{t('displayRange', language)}</div>
<div className="text-sm md:text-base font-bold mono" style={{ color: '#EAECEF' }}>
<div
className="p-2 md:p-3 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className="text-sm md:text-base font-bold mono"
style={{ color: '#EAECEF' }}
>
{combinedData.length > MAX_DISPLAY_POINTS
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
: t('allData', language)}
@@ -362,5 +472,5 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
</div>
</div>
</div>
);
)
}
+238 -94
View File
@@ -1,18 +1,18 @@
import { useState } from 'react';
import { Trophy, Medal } from 'lucide-react';
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionData } from '../types';
import { ComparisonChart } from './ComparisonChart';
import { TraderConfigViewModal } from './TraderConfigViewModal';
import { getTraderColor } from '../utils/traderColors';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { useState } from 'react'
import { Trophy, Medal } from 'lucide-react'
import useSWR from 'swr'
import { api } from '../lib/api'
import type { CompetitionData } from '../types'
import { ComparisonChart } from './ComparisonChart'
import { TraderConfigViewModal } from './TraderConfigViewModal'
import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
export function CompetitionPage() {
const { language } = useLanguage();
const [selectedTrader, setSelectedTrader] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const { language } = useLanguage()
const [selectedTrader, setSelectedTrader] = useState<any>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const { data: competition } = useSWR<CompetitionData>(
'competition',
@@ -22,24 +22,24 @@ export function CompetitionPage() {
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
)
const handleTraderClick = async (traderId: string) => {
try {
const traderConfig = await api.getTraderConfig(traderId);
setSelectedTrader(traderConfig);
setIsModalOpen(true);
const traderConfig = await api.getTraderConfig(traderId)
setSelectedTrader(traderConfig)
setIsModalOpen(true)
} catch (error) {
console.error('Failed to fetch trader config:', error);
console.error('Failed to fetch trader config:', error)
// 对于未登录用户,不显示详细配置,这是正常行为
// 竞赛页面主要用于查看排行榜和基本信息
}
};
}
const closeModal = () => {
setIsModalOpen(false);
setSelectedTrader(null);
};
setIsModalOpen(false)
setSelectedTrader(null)
}
if (!competition) {
return (
@@ -61,7 +61,7 @@ export function CompetitionPage() {
</div>
</div>
</div>
);
)
}
// 如果有数据返回但没有交易员,显示空状态
@@ -71,16 +71,31 @@ export function CompetitionPage() {
{/* Competition Header - 精简版 */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
}}
>
<Trophy
className="w-6 h-6 md:w-7 md:h-7"
style={{ color: '#000' }}
/>
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h1
className="text-xl md:text-2xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('aiCompetition', language)}
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
<span
className="text-xs font-normal px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.15)',
color: '#F0B90B',
}}
>
0 {t('traders', language)}
</span>
</h1>
@@ -93,7 +108,10 @@ export function CompetitionPage() {
{/* Empty State */}
<div className="binance-card p-8 text-center">
<Trophy className="w-16 h-16 mx-auto mb-4 opacity-40" style={{ color: '#848E9C' }} />
<Trophy
className="w-16 h-16 mx-auto mb-4 opacity-40"
style={{ color: '#848E9C' }}
/>
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
{t('noTraders', language)}
</h3>
@@ -102,32 +120,47 @@ export function CompetitionPage() {
</p>
</div>
</div>
);
)
}
// 按收益率排序
const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct
);
)
// 找出领先者
const leader = sortedTraders[0];
const leader = sortedTraders[0]
return (
<div className="space-y-5 animate-fade-in">
{/* Competition Header - 精简版 */}
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-3 md:gap-0">
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center" style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)'
}}>
<Trophy className="w-6 h-6 md:w-7 md:h-7" style={{ color: '#000' }} />
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
boxShadow: '0 4px 14px rgba(240, 185, 11, 0.4)',
}}
>
<Trophy
className="w-6 h-6 md:w-7 md:h-7"
style={{ color: '#000' }}
/>
</div>
<div>
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h1
className="text-xl md:text-2xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('aiCompetition', language)}
<span className="text-xs font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
<span
className="text-xs font-normal px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.15)',
color: '#F0B90B',
}}
>
{competition.count} {t('traders', language)}
</span>
</h1>
@@ -137,10 +170,23 @@ export function CompetitionPage() {
</div>
</div>
<div className="text-left md:text-right w-full md:w-auto">
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('leader', language)}</div>
<div className="text-base md:text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
<div className="text-sm font-semibold" style={{ color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('leader', language)}
</div>
<div
className="text-base md:text-lg font-bold"
style={{ color: '#F0B90B' }}
>
{leader?.trader_name}
</div>
<div
className="text-sm font-semibold"
style={{
color: (leader?.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
}}
>
{(leader?.total_pnl ?? 0) >= 0 ? '+' : ''}
{leader?.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
</div>
</div>
@@ -148,9 +194,15 @@ export function CompetitionPage() {
{/* Left/Right Split: Performance Chart + Leaderboard */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Left: Performance Comparison Chart */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.1s' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('performanceComparison', language)}
</h2>
<div className="text-xs" style={{ color: '#848E9C' }}>
@@ -161,19 +213,35 @@ export function CompetitionPage() {
</div>
{/* Right: Leaderboard */}
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.1s' }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<h2
className="text-lg font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('leaderboard', language)}
</h2>
<div className="text-xs px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
<div
className="text-xs px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.1)',
color: '#F0B90B',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
{t('live', language)}
</div>
</div>
<div className="space-y-2">
{sortedTraders.map((trader, index) => {
const isLeader = index === 0;
const traderColor = getTraderColor(sortedTraders, trader.trader_id);
const isLeader = index === 0
const traderColor = getTraderColor(
sortedTraders,
trader.trader_id
)
return (
<div
@@ -181,21 +249,44 @@ export function CompetitionPage() {
onClick={() => handleTraderClick(trader.trader_id)}
className="rounded p-3 transition-all duration-300 hover:translate-y-[-1px] cursor-pointer hover:shadow-lg"
style={{
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
background: isLeader
? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)'
: '#0B0E11',
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
boxShadow: isLeader ? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)' : '0 1px 4px rgba(0, 0, 0, 0.3)'
boxShadow: isLeader
? '0 3px 15px rgba(240, 185, 11, 0.12), 0 0 0 1px rgba(240, 185, 11, 0.15)'
: '0 1px 4px rgba(0, 0, 0, 0.3)',
}}
>
<div className="flex items-center justify-between">
{/* Rank & Name */}
<div className="flex items-center gap-3">
<div className="w-6 flex items-center justify-center">
<Medal className="w-5 h-5" style={{ color: index === 0 ? '#F0B90B' : index === 1 ? '#C0C0C0' : '#CD7F32' }} />
<Medal
className="w-5 h-5"
style={{
color:
index === 0
? '#F0B90B'
: index === 1
? '#C0C0C0'
: '#CD7F32',
}}
/>
</div>
<div>
<div className="font-bold text-sm" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
<div className="text-xs mono font-semibold" style={{ color: traderColor }}>
{trader.ai_model.toUpperCase()} + {trader.exchange.toUpperCase()}
<div
className="font-bold text-sm"
style={{ color: '#EAECEF' }}
>
{trader.trader_name}
</div>
<div
className="text-xs mono font-semibold"
style={{ color: traderColor }}
>
{trader.ai_model.toUpperCase()} +{' '}
{trader.exchange.toUpperCase()}
</div>
</div>
</div>
@@ -204,31 +295,52 @@ export function CompetitionPage() {
<div className="flex items-center gap-2 md:gap-3 flex-wrap md:flex-nowrap">
{/* Total Equity */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('equity', language)}</div>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('equity', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.total_equity?.toFixed(2) || '0.00'}
</div>
</div>
{/* P&L */}
<div className="text-right min-w-[70px] md:min-w-[90px]">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pnl', language)}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pnl', language)}
</div>
<div
className="text-base md:text-lg font-bold mono"
style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}
style={{
color:
(trader.total_pnl ?? 0) >= 0
? '#0ECB81'
: '#F6465D',
}}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
<div className="text-xs mono" style={{ color: '#848E9C' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl?.toFixed(2) || '0.00'}
<div
className="text-xs mono"
style={{ color: '#848E9C' }}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl?.toFixed(2) || '0.00'}
</div>
</div>
{/* Positions */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>{t('pos', language)}</div>
<div className="text-xs md:text-sm font-bold mono" style={{ color: '#EAECEF' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('pos', language)}
</div>
<div
className="text-xs md:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{trader.position_count}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
@@ -240,9 +352,16 @@ export function CompetitionPage() {
<div>
<div
className="px-2 py-1 rounded text-xs font-bold"
style={trader.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
style={
trader.is_running
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
}
>
{trader.is_running ? '●' : '○'}
@@ -251,7 +370,7 @@ export function CompetitionPage() {
</div>
</div>
</div>
);
)
})}
</div>
</div>
@@ -259,56 +378,81 @@ export function CompetitionPage() {
{/* Head-to-Head Stats */}
{competition.traders.length === 2 && (
<div className="binance-card p-5 animate-slide-in" style={{ animationDelay: '0.3s' }}>
<h2 className="text-lg font-bold mb-4 flex items-center gap-2" style={{ color: '#EAECEF' }}>
<div
className="binance-card p-5 animate-slide-in"
style={{ animationDelay: '0.3s' }}
>
<h2
className="text-lg font-bold mb-4 flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{t('headToHead', language)}
</h2>
<div className="grid grid-cols-2 gap-4">
{sortedTraders.map((trader, index) => {
const isWinning = index === 0;
const opponent = sortedTraders[1 - index];
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
const isWinning = index === 0
const opponent = sortedTraders[1 - index]
const gap = trader.total_pnl_pct - opponent.total_pnl_pct
return (
<div
key={trader.trader_id}
className="p-4 rounded transition-all duration-300 hover:scale-[1.02]"
style={isWinning
? {
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)'
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)'
}
style={
isWinning
? {
background:
'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 3px 15px rgba(14, 203, 129, 0.12)',
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.3)',
}
}
>
<div className="text-center">
<div
className="text-sm md:text-base font-bold mb-2"
style={{ color: getTraderColor(sortedTraders, trader.trader_id) }}
style={{
color: getTraderColor(sortedTraders, trader.trader_id),
}}
>
{trader.trader_name}
</div>
<div className="text-lg md:text-2xl font-bold mono mb-1" style={{ color: (trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
<div
className="text-lg md:text-2xl font-bold mono mb-1"
style={{
color:
(trader.total_pnl ?? 0) >= 0 ? '#0ECB81' : '#F6465D',
}}
>
{(trader.total_pnl ?? 0) >= 0 ? '+' : ''}
{trader.total_pnl_pct?.toFixed(2) || '0.00'}%
</div>
{isWinning && gap > 0 && (
<div className="text-xs font-semibold" style={{ color: '#0ECB81' }}>
<div
className="text-xs font-semibold"
style={{ color: '#0ECB81' }}
>
{t('leadingBy', language, { gap: gap.toFixed(2) })}
</div>
)}
{!isWinning && gap < 0 && (
<div className="text-xs font-semibold" style={{ color: '#F6465D' }}>
{t('behindBy', language, { gap: Math.abs(gap).toFixed(2) })}
<div
className="text-xs font-semibold"
style={{ color: '#F6465D' }}
>
{t('behindBy', language, {
gap: Math.abs(gap).toFixed(2),
})}
</div>
)}
</div>
</div>
);
)
})}
</div>
</div>
@@ -321,5 +465,5 @@ export function CompetitionPage() {
traderData={selectedTrader}
/>
</div>
);
)
}
+116 -95
View File
@@ -1,115 +1,136 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Check } from "lucide-react";
import { cn } from "../lib/utils";
import * as React from 'react'
import { motion } from 'framer-motion'
import { Check } from 'lucide-react'
import { cn } from '../lib/utils'
interface CryptoFeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
features: string[];
className?: string;
delay?: number;
icon: React.ReactNode
title: string
description: string
features: string[]
className?: string
delay?: number
}
export const CryptoFeatureCard = React.forwardRef<HTMLDivElement, CryptoFeatureCardProps>(
({ icon, title, description, features, className, delay = 0 }, ref) => {
const [isHovered, setIsHovered] = React.useState(false);
export const CryptoFeatureCard = React.forwardRef<
HTMLDivElement,
CryptoFeatureCardProps
>(({ icon, title, description, features, className, delay = 0 }, ref) => {
const [isHovered, setIsHovered] = React.useState(false)
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
className="relative h-full"
>
<div
className={cn(
'relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl',
'bg-gradient-to-br from-[#000000] to-[#0A0A0A]',
'border-[#1A1A1A] hover:border-[#F0B90B]/50',
isHovered && 'shadow-[0_0_20px_rgba(240,185,11,0.2)]',
className
)}
>
<div
className={cn(
"relative h-full overflow-hidden border-2 transition-all duration-300 rounded-xl",
"bg-gradient-to-br from-[#000000] to-[#0A0A0A]",
"border-[#1A1A1A] hover:border-[#F0B90B]/50",
isHovered && "shadow-[0_0_20px_rgba(240,185,11,0.2)]",
className
)}
{/* Animated glow border effect */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
animate={{
opacity: isHovered ? 1 : 0,
}}
transition={{ duration: 0.3 }}
>
{/* Animated glow border effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: '32px 32px',
}}
/>
</div>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className="absolute inset-0 opacity-0 pointer-events-none"
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
style={{
background:
'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? '0 0 20px rgba(240, 185, 11, 0.4)'
: '0 0 0px rgba(240, 185, 11, 0)',
}}
transition={{ duration: 0.3 }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#F0B90B]/20 to-transparent animate-[shimmer_2s_infinite]" />
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
</motion.div>
{/* Background pattern */}
<div className="absolute inset-0 opacity-5">
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle at 2px 2px, #F0B90B 1px, transparent 0)`,
backgroundSize: "32px 32px",
}}
/>
</div>
{/* Title */}
<h3
className="text-2xl font-bold mb-3"
style={{ color: 'var(--brand-light-gray)' }}
>
{title}
</h3>
<div className="relative z-10 p-8 flex flex-col h-full">
{/* Icon container */}
<motion.div
className="mb-6 inline-flex items-center justify-center w-16 h-16 rounded-xl"
style={{
background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.2) 0%, rgba(240, 185, 11, 0.05) 100%)',
border: '1px solid rgba(240, 185, 11, 0.3)'
}}
animate={{
scale: isHovered ? 1.1 : 1,
boxShadow: isHovered
? "0 0 20px rgba(240, 185, 11, 0.4)"
: "0 0 0px rgba(240, 185, 11, 0)",
}}
transition={{ duration: 0.3 }}
>
<div style={{ color: 'var(--brand-yellow)' }}>{icon}</div>
</motion.div>
{/* Description */}
<p
className="mb-6 flex-grow leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
{/* Title */}
<h3 className="text-2xl font-bold mb-3" style={{ color: 'var(--brand-light-gray)' }}>{title}</h3>
{/* Description */}
<p className="mb-6 flex-grow leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{description}</p>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div className="w-5 h-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(240, 185, 11, 0.2)' }}>
<Check className="w-3 h-3" style={{ color: 'var(--brand-yellow)' }} />
</div>
{/* Features list */}
<div className="space-y-3 mb-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: delay + index * 0.1 }}
className="flex items-start gap-3"
>
<div className="mt-0.5 flex-shrink-0">
<div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: 'rgba(240, 185, 11, 0.2)' }}
>
<Check
className="w-3 h-3"
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
<span className="text-sm" style={{ color: 'var(--brand-light-gray)' }}>{feature}</span>
</motion.div>
))}
</div>
</div>
<span
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{feature}
</span>
</motion.div>
))}
</div>
</div>
</motion.div>
);
}
);
</div>
</motion.div>
)
})
CryptoFeatureCard.displayName = "CryptoFeatureCard";
CryptoFeatureCard.displayName = 'CryptoFeatureCard'
+133 -112
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from 'react'
import {
LineChart,
Line,
@@ -8,28 +8,35 @@ import {
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import useSWR from 'swr';
import { api } from '../lib/api';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { AlertTriangle, BarChart3, DollarSign, Percent, TrendingUp as ArrowUp, TrendingDown as ArrowDown } from 'lucide-react'
} from 'recharts'
import useSWR from 'swr'
import { api } from '../lib/api'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import {
AlertTriangle,
BarChart3,
DollarSign,
Percent,
TrendingUp as ArrowUp,
TrendingDown as ArrowDown,
} from 'lucide-react'
interface EquityPoint {
timestamp: string;
total_equity: number;
pnl: number;
pnl_pct: number;
cycle_number: number;
timestamp: string
total_equity: number
pnl: number
pnl_pct: number
cycle_number: number
}
interface EquityChartProps {
traderId?: string;
traderId?: string
}
export function EquityChart({ traderId }: EquityChartProps) {
const { language } = useLanguage();
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar');
const { language } = useLanguage()
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar')
const { data: history, error } = useSWR<EquityPoint[]>(
traderId ? `equity-history-${traderId}` : 'equity-history',
@@ -39,7 +46,7 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 20000,
}
);
)
const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account',
@@ -49,24 +56,24 @@ export function EquityChart({ traderId }: EquityChartProps) {
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
)
if (error) {
return (
<div className='binance-card p-6'>
<div className="binance-card p-6">
<div
className='flex items-center gap-3 p-4 rounded'
className="flex items-center gap-3 p-4 rounded"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.2)',
}}
>
<AlertTriangle className='w-6 h-6' style={{ color: '#F6465D' }} />
<AlertTriangle className="w-6 h-6" style={{ color: '#F6465D' }} />
<div>
<div className='font-semibold' style={{ color: '#F6465D' }}>
<div className="font-semibold" style={{ color: '#F6465D' }}>
{t('loadingError', language)}
</div>
<div className='text-sm' style={{ color: '#848E9C' }}>
<div className="text-sm" style={{ color: '#848E9C' }}>
{error.message}
</div>
</div>
@@ -76,22 +83,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
}
// 过滤掉无效数据:total_equity为0或小于1的数据点(API失败导致)
const validHistory = history?.filter(point => point.total_equity > 1) || [];
const validHistory = history?.filter((point) => point.total_equity > 1) || []
if (!validHistory || validHistory.length === 0) {
return (
<div className='binance-card p-6'>
<h3 className='text-lg font-semibold mb-6' style={{ color: '#EAECEF' }}>
<div className="binance-card p-6">
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
{t('accountEquityCurve', language)}
</h3>
<div className='text-center py-16' style={{ color: '#848E9C' }}>
<div className='mb-4 flex justify-center opacity-50'>
<BarChart3 className='w-16 h-16' />
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="mb-4 flex justify-center opacity-50">
<BarChart3 className="w-16 h-16" />
</div>
<div className='text-lg font-semibold mb-2'>
<div className="text-lg font-semibold mb-2">
{t('noHistoricalData', language)}
</div>
<div className='text-sm'>{t('dataWillAppear', language)}</div>
<div className="text-sm">{t('dataWillAppear', language)}</div>
</div>
</div>
)
@@ -99,20 +106,21 @@ export function EquityChart({ traderId }: EquityChartProps) {
// 限制显示最近的数据点(性能优化)
// 如果数据超过2000个点,只显示最近2000个
const MAX_DISPLAY_POINTS = 2000;
const displayHistory = validHistory.length > MAX_DISPLAY_POINTS
? validHistory.slice(-MAX_DISPLAY_POINTS)
: validHistory;
const MAX_DISPLAY_POINTS = 2000
const displayHistory =
validHistory.length > MAX_DISPLAY_POINTS
? validHistory.slice(-MAX_DISPLAY_POINTS)
: validHistory
// 计算初始余额(使用第一个有效数据点,如果无数据则从account获取,最后才用默认值
const initialBalance = validHistory[0]?.total_equity
|| account?.total_equity
|| 100; // 默认值改为100,与常见配置一致
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推
const initialBalance = account?.initial_balance // 从交易员配置读取真实初始余额
|| (validHistory[0] ? validHistory[0].total_equity - validHistory[0].pnl : undefined) // 备选:淨值 - 盈亏
|| 1000; // 默认值(与创建交易员时的默认配置一致
// 转换数据格式
const chartData = displayHistory.map((point) => {
const pnl = point.total_equity - initialBalance;
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2);
const pnl = point.total_equity - initialBalance
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
return {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -123,43 +131,45 @@ export function EquityChart({ traderId }: EquityChartProps) {
raw_equity: point.total_equity,
raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct),
};
});
}
})
const currentValue = chartData[chartData.length - 1];
const isProfit = currentValue.raw_pnl >= 0;
const currentValue = chartData[chartData.length - 1]
const isProfit = currentValue.raw_pnl >= 0
// 计算Y轴范围
const calculateYDomain = () => {
if (displayMode === 'percent') {
// 百分比模式:找到最大最小值,留20%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)];
const values = chartData.map((d) => d.value)
const minVal = Math.min(...values)
const maxVal = Math.max(...values)
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
} else {
// 美元模式:以初始余额为基准,上下留10%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values, initialBalance);
const maxVal = Math.max(...values, initialBalance);
const range = maxVal - minVal;
const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量
return [
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
const values = chartData.map((d) => d.value)
const minVal = Math.min(...values, initialBalance)
const maxVal = Math.max(...values, initialBalance)
const range = maxVal - minVal
const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
}
};
}
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
const data = payload[0].payload
return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</div>
<div
className="rounded p-3 shadow-xl"
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
Cycle #{data.cycle}
</div>
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT
</div>
@@ -172,38 +182,38 @@ export function EquityChart({ traderId }: EquityChartProps) {
{data.raw_pnl_pct}%)
</div>
</div>
);
)
}
return null;
};
return null
}
return (
<div className='binance-card p-3 sm:p-5 animate-fade-in'>
<div className="binance-card p-3 sm:p-5 animate-fade-in">
{/* Header */}
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4'>
<div className='flex-1'>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex-1">
<h3
className='text-base sm:text-lg font-bold mb-2'
className="text-base sm:text-lg font-bold mb-2"
style={{ color: '#EAECEF' }}
>
{t('accountEquityCurve', language)}
</h3>
<div className='flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4'>
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
<span
className='text-2xl sm:text-3xl font-bold mono'
className="text-2xl sm:text-3xl font-bold mono"
style={{ color: '#EAECEF' }}
>
{account?.total_equity.toFixed(2) || '0.00'}
<span
className='text-base sm:text-lg ml-1'
className="text-base sm:text-lg ml-1"
style={{ color: '#848E9C' }}
>
USDT
</span>
</span>
<div className='flex items-center gap-2 flex-wrap'>
<div className="flex items-center gap-2 flex-wrap">
<span
className='text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1'
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
style={{
color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit
@@ -216,12 +226,16 @@ export function EquityChart({ traderId }: EquityChartProps) {
}`,
}}
>
{isProfit ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
{isProfit ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
{isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
</span>
<span
className='text-xs sm:text-sm mono'
className="text-xs sm:text-sm mono"
style={{ color: '#848E9C' }}
>
({isProfit ? '+' : ''}
@@ -233,12 +247,12 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Display Mode Toggle */}
<div
className='flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto'
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<button
onClick={() => setDisplayMode('dollar')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'dollar'
? {
@@ -249,11 +263,11 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<DollarSign className='w-4 h-4' /> USDT
<DollarSign className="w-4 h-4" /> USDT
</button>
<button
onClick={() => setDisplayMode('percent')}
className='px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1'
className="px-3 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-sm font-bold transition-all flex items-center gap-1"
style={
displayMode === 'percent'
? {
@@ -264,15 +278,22 @@ export function EquityChart({ traderId }: EquityChartProps) {
: { background: 'transparent', color: '#848E9C' }
}
>
<Percent className='w-4 h-4' />
<Percent className="w-4 h-4" />
</button>
</div>
</div>
{/* Chart */}
<div className='my-2' style={{ borderRadius: '8px', overflow: 'hidden', position: 'relative' }}>
<div
className="my-2"
style={{
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
}}
>
{/* NOFX Watermark */}
<div
<div
style={{
position: 'absolute',
top: '15px',
@@ -282,35 +303,35 @@ export function EquityChart({ traderId }: EquityChartProps) {
color: 'rgba(240, 185, 11, 0.15)',
zIndex: 10,
pointerEvents: 'none',
fontFamily: 'monospace'
fontFamily: 'monospace',
}}
>
NOFX
</div>
<ResponsiveContainer width='100%' height={280}>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={chartData}
margin={{ top: 10, right: 20, left: 5, bottom: 30 }}
>
<defs>
<linearGradient id='colorGradient' x1='0' y1='0' x2='0' y2='1'>
<stop offset='5%' stopColor='#F0B90B' stopOpacity={0.8} />
<stop offset='95%' stopColor='#FCD535' stopOpacity={0.2} />
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray='3 3' stroke='#2B3139' />
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey='time'
stroke='#5E6673'
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor='end'
textAnchor="end"
height={60}
/>
<YAxis
stroke='#5E6673'
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
@@ -321,8 +342,8 @@ export function EquityChart({ traderId }: EquityChartProps) {
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke='#474D57'
strokeDasharray='3 3'
stroke="#474D57"
strokeDasharray="3 3"
label={{
value:
displayMode === 'dollar'
@@ -333,9 +354,9 @@ export function EquityChart({ traderId }: EquityChartProps) {
}}
/>
<Line
type='natural'
dataKey='value'
stroke='url(#colorGradient)'
type="natural"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={3}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{
@@ -352,72 +373,72 @@ export function EquityChart({ traderId }: EquityChartProps) {
{/* Footer Stats */}
<div
className='mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3'
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
style={{ borderTop: '1px solid #2B3139' }}
>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('initialBalance', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('currentEquity', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{currentValue.raw_equity.toFixed(2)} USDT
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('historicalCycles', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length} {t('cycles', language)}
</div>
</div>
<div
className='p-2 rounded transition-all hover:bg-opacity-50'
className="p-2 rounded transition-all hover:bg-opacity-50"
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
>
<div
className='text-xs mb-1 uppercase tracking-wider'
className="text-xs mb-1 uppercase tracking-wider"
style={{ color: '#848E9C' }}
>
{t('displayRange', language)}
</div>
<div
className='text-xs sm:text-sm font-bold mono'
className="text-xs sm:text-sm font-bold mono"
style={{ color: '#EAECEF' }}
>
{validHistory.length > MAX_DISPLAY_POINTS
+119 -61
View File
@@ -1,107 +1,165 @@
import React from 'react';
import React from 'react'
interface IconProps {
width?: number;
height?: number;
className?: string;
width?: number
height?: number
className?: string
}
// Binance SVG 图标组件
const BinanceIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
const BinanceIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
viewBox="-52.785 -88 457.47 528"
className={className}
>
<path
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
<path
d="M79.5 176l-39.7 39.7L0 176l39.7-39.7zM176 79.5l68.1 68.1 39.7-39.7L176 0 68.1 107.9l39.7 39.7zm136.2 56.8L272.5 176l39.7 39.7 39.7-39.7zM176 272.5l-68.1-68.1-39.7 39.7L176 352l107.8-107.9-39.7-39.7zm0-56.8l39.7-39.7-39.7-39.7-39.8 39.7z"
fill="#f0b90b"
/>
</svg>
);
)
// Hyperliquid SVG 图标组件
const HyperliquidIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 144 144"
fill="none"
const HyperliquidIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
width={width}
height={height}
viewBox="0 0 144 144"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
<path
d="M144 71.6991C144 119.306 114.866 134.582 99.5156 120.98C86.8804 109.889 83.1211 86.4521 64.116 84.0456C39.9942 81.0113 37.9057 113.133 22.0334 113.133C3.5504 113.133 0 86.2428 0 72.4315C0 58.3063 3.96809 39.0542 19.736 39.0542C38.1146 39.0542 39.1588 66.5722 62.132 65.1073C85.0007 63.5379 85.4184 34.8689 100.247 22.6271C113.195 12.0593 144 23.4641 144 71.6991Z"
fill="#97FCE4"
/>
</svg>
);
)
// Aster SVG 图标组件
const AsterIcon: React.FC<IconProps> = ({ width = 24, height = 24, className }) => (
<svg
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
const AsterIcon: React.FC<IconProps> = ({
width = 24,
height = 24,
className,
}) => (
<svg
width={width}
height={height}
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="paint0_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint0_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint1_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint1_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint2_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<stop offset="1" stopColor="#FFD29F"/>
<linearGradient
id="paint2_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
<stop offset="1" stopColor="#FFD29F" />
</linearGradient>
<linearGradient id="paint3_linear_428_3535" x1="18.9416" y1="4.14314e-07" x2="12.6408" y2="32.0507" gradientUnits="userSpaceOnUse">
<stop stopColor="#F4D5B1"/>
<linearGradient
id="paint3_linear_428_3535"
x1="18.9416"
y1="4.14314e-07"
x2="12.6408"
y2="32.0507"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#F4D5B1" />
</linearGradient>
</defs>
<path d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z" fill="url(#paint0_linear_428_3535)"/>
<path d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z" fill="url(#paint1_linear_428_3535)"/>
<path d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z" fill="url(#paint2_linear_428_3535)"/>
<path d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z" fill="url(#paint3_linear_428_3535)"/>
<path
d="M9.13309 30.4398L9.88315 26.9871C10.7197 23.1362 7.77521 19.4988 3.82118 19.4988H0.385363C1.4689 24.3374 4.75127 28.3496 9.13309 30.4398Z"
fill="url(#paint0_linear_428_3535)"
/>
<path
d="M10.64 31.0663C12.3326 31.6707 14.1567 32 16.0579 32C23.7199 32 30.1285 26.6527 31.7305 19.4988H21.249C16.5244 19.4988 12.4396 22.7824 11.44 27.3838L10.64 31.0663Z"
fill="url(#paint1_linear_428_3535)"
/>
<path
d="M32.0038 17.8987C32.0778 17.2756 32.1159 16.6415 32.1159 15.9985C32.1159 7.60402 25.629 0.719287 17.3779 0.0503251L15.1273 10.4105C14.2907 14.2614 17.2352 17.8987 21.1892 17.8987H32.0038Z"
fill="url(#paint2_linear_428_3535)"
/>
<path
d="M15.7459 0C7.02134 0.165717 0 7.26504 0 15.9985C0 16.6415 0.0380539 17.2756 0.112041 17.8987H3.76146C8.48603 17.8987 12.5709 14.6151 13.5705 10.0137L15.7459 0Z"
fill="url(#paint3_linear_428_3535)"
/>
</svg>
);
)
// 获取交易所图标的函数
export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) => {
export const getExchangeIcon = (
exchangeType: string,
props: IconProps = {}
) => {
// 支持完整ID或类型名
const type = exchangeType.toLowerCase().includes('binance') ? 'binance' :
exchangeType.toLowerCase().includes('hyperliquid') ? 'hyperliquid' :
exchangeType.toLowerCase().includes('aster') ? 'aster' :
exchangeType.toLowerCase();
const type = exchangeType.toLowerCase().includes('binance')
? 'binance'
: exchangeType.toLowerCase().includes('hyperliquid')
? 'hyperliquid'
: exchangeType.toLowerCase().includes('aster')
? 'aster'
: exchangeType.toLowerCase()
const iconProps = {
width: props.width || 24,
height: props.height || 24,
className: props.className
};
className: props.className,
}
switch (type) {
case 'binance':
case 'cex':
return <BinanceIcon {...iconProps} />;
return <BinanceIcon {...iconProps} />
case 'hyperliquid':
case 'dex':
return <HyperliquidIcon {...iconProps} />;
return <HyperliquidIcon {...iconProps} />
case 'aster':
return <AsterIcon {...iconProps} />;
return <AsterIcon {...iconProps} />
default:
return (
<div
<div
className={props.className}
style={{
width: props.width || 24,
style={{
width: props.width || 24,
height: props.height || 24,
borderRadius: '50%',
background: '#2B3139',
@@ -110,11 +168,11 @@ export const getExchangeIcon = (exchangeType: string, props: IconProps = {}) =>
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
color: '#EAECEF'
color: '#EAECEF',
}}
>
{type[0]?.toUpperCase() || '?'}
</div>
);
)
}
};
}
+18 -13
View File
@@ -1,12 +1,12 @@
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
interface HeaderProps {
simple?: boolean; // For login/register pages
simple?: boolean // For login/register pages
}
export function Header({ simple = false }: HeaderProps) {
const { language, setLanguage } = useLanguage();
const { language, setLanguage } = useLanguage()
return (
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
@@ -28,15 +28,19 @@ export function Header({ simple = false }: HeaderProps) {
)}
</div>
</div>
{/* Right - Language Toggle (always show) */}
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<div
className="flex gap-1 rounded p-1"
style={{ background: '#1E2329' }}
>
<button
onClick={() => setLanguage('zh')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
style={
language === 'zh'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
@@ -44,9 +48,10 @@ export function Header({ simple = false }: HeaderProps) {
<button
onClick={() => setLanguage('en')}
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
style={language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
style={
language === 'en'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
EN
@@ -55,5 +60,5 @@ export function Header({ simple = false }: HeaderProps) {
</div>
</div>
</header>
);
)
}
+219 -155
View File
@@ -1,208 +1,272 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import HeaderBar from './landing/HeaderBar';
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import HeaderBar from './landing/HeaderBar'
export function LoginPage() {
const { language } = useLanguage();
const { login, verifyOTP } = useAuth();
const [step, setStep] = useState<'login' | 'otp'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { language } = useLanguage()
const { login, verifyOTP } = useAuth()
const [step, setStep] = useState<'login' | 'otp'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
e.preventDefault()
setError('')
setLoading(true)
const result = await login(email, password)
const result = await login(email, password);
if (result.success) {
if (result.requiresOTP && result.userID) {
setUserID(result.userID);
setStep('otp');
setUserID(result.userID)
setStep('otp')
}
} else {
setError(result.message || t('loginFailed', language));
setError(result.message || t('loginFailed', language))
}
setLoading(false);
};
setLoading(false)
}
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
e.preventDefault()
setError('')
setLoading(true)
const result = await verifyOTP(userID, otpCode)
const result = await verifyOTP(userID, otpCode);
if (!result.success) {
setError(result.message || t('verificationFailed', language));
setError(result.message || t('verificationFailed', language))
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
setLoading(false)
}
return (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
<HeaderBar
onLoginClick={() => {}}
isLoggedIn={false}
<HeaderBar
onLoginClick={() => {}}
isLoggedIn={false}
isHomePage={false}
currentPage="login"
language={language}
onLanguageChange={() => {}}
onPageChange={(page) => {
console.log('LoginPage onPageChange called with:', page);
console.log('LoginPage onPageChange called with:', page)
if (page === 'competition') {
window.location.href = '/competition';
window.location.href = '/competition'
}
}}
/>
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div
className="flex items-center justify-center pt-20"
style={{ minHeight: 'calc(100vh - 80px)' }}
>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-bold" style={{ color: 'var(--brand-light-gray)' }}>
<h1
className="text-2xl font-bold"
style={{ color: 'var(--brand-light-gray)' }}
>
NOFX
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
<p
className="text-sm mt-2"
style={{ color: 'var(--text-secondary)' }}
>
{step === 'login' ? '请输入您的邮箱和密码' : '请输入两步验证码'}
</p>
</div>
{/* Login Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
{/* Login Form */}
<div
className="rounded-lg p-6"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
>
{step === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{loading ? t('loading', language) : t('loginButton', language)}
</button>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}<br />
{t('enterOTPCode', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
>
{t('back', language)}
</button>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
disabled={loading}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{loading ? t('loading', language) : t('verifyOTP', language)}
{loading
? t('loading', language)
: t('loginButton', language)}
</button>
</div>
</form>
)}
</div>
</form>
) : (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">📱</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('scanQRCodeInstructions', language)}
<br />
{t('enterOTPCode', language)}
</p>
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('login')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--text-secondary)',
}}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('verifyOTP', language)}
</button>
</div>
</form>
)}
</div>
{/* Register Link */}
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/register')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
</div>
</div>
</div>
</div>
</div>
);
)
}
+14 -15
View File
@@ -1,26 +1,25 @@
interface IconProps {
width?: number;
height?: number;
className?: string;
width?: number
height?: number
className?: string
}
// 获取AI模型图标的函数
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
// 支持完整ID或类型名
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType;
let iconPath: string | null = null;
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
let iconPath: string | null = null
switch (type) {
case 'deepseek':
iconPath = '/icons/deepseek.svg';
break;
iconPath = '/icons/deepseek.svg'
break
case 'qwen':
iconPath = '/icons/qwen.svg';
break;
iconPath = '/icons/qwen.svg'
break
default:
return null;
return null
}
return (
@@ -32,5 +31,5 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
className={props.className}
style={{ borderRadius: '50%' }}
/>
);
};
)
}
+423 -287
View File
@@ -1,366 +1,502 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { getSystemConfig } from '../lib/config';
import HeaderBar from './landing/HeaderBar';
import React, { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { getSystemConfig } from '../lib/config'
import HeaderBar from './landing/HeaderBar'
export function RegisterPage() {
const { language } = useLanguage();
const { register, completeRegistration } = useAuth();
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>('register');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [betaCode, setBetaCode] = useState('');
const [betaMode, setBetaMode] = useState(false);
const [otpCode, setOtpCode] = useState('');
const [userID, setUserID] = useState('');
const [otpSecret, setOtpSecret] = useState('');
const [qrCodeURL, setQrCodeURL] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { language } = useLanguage()
const { register, completeRegistration } = useAuth()
const [step, setStep] = useState<'register' | 'setup-otp' | 'verify-otp'>(
'register'
)
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [betaCode, setBetaCode] = useState('')
const [betaMode, setBetaMode] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [otpSecret, setOtpSecret] = useState('')
const [qrCodeURL, setQrCodeURL] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式
getSystemConfig().then(config => {
setBetaMode(config.beta_mode || false);
}).catch(err => {
console.error('Failed to fetch system config:', err);
});
}, []);
getSystemConfig()
.then((config) => {
setBetaMode(config.beta_mode || false)
})
.catch((err) => {
console.error('Failed to fetch system config:', err)
})
}, [])
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError(t('passwordMismatch', language));
return;
setError(t('passwordMismatch', language))
return
}
if (password.length < 6) {
setError(t('passwordTooShort', language));
return;
setError(t('passwordTooShort', language))
return
}
if (betaMode && !betaCode.trim()) {
setError('内测期间,注册需要提供内测码');
return;
setError('内测期间,注册需要提供内测码')
return
}
setLoading(true);
setLoading(true)
const result = await register(email, password, betaCode.trim() || undefined)
const result = await register(email, password, betaCode.trim() || undefined);
if (result.success && result.userID) {
setUserID(result.userID);
setOtpSecret(result.otpSecret || '');
setQrCodeURL(result.qrCodeURL || '');
setStep('setup-otp');
setUserID(result.userID)
setOtpSecret(result.otpSecret || '')
setQrCodeURL(result.qrCodeURL || '')
setStep('setup-otp')
} else {
setError(result.message || t('registrationFailed', language));
setError(result.message || t('registrationFailed', language))
}
setLoading(false);
};
setLoading(false)
}
const handleSetupComplete = () => {
setStep('verify-otp');
};
setStep('verify-otp')
}
const handleOTPVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
e.preventDefault()
setError('')
setLoading(true)
const result = await completeRegistration(userID, otpCode)
const result = await completeRegistration(userID, otpCode);
if (!result.success) {
setError(result.message || t('registrationFailed', language));
setError(result.message || t('registrationFailed', language))
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false);
};
setLoading(false)
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
navigator.clipboard.writeText(text)
}
return (
<div className="min-h-screen" style={{ background: 'var(--brand-black)' }}>
<HeaderBar
isLoggedIn={false}
<HeaderBar
isLoggedIn={false}
isHomePage={false}
currentPage="register"
language={language}
onLanguageChange={() => {}}
onPageChange={(page) => {
console.log('RegisterPage onPageChange called with:', page);
console.log('RegisterPage onPageChange called with:', page)
if (page === 'competition') {
window.location.href = '/competition';
window.location.href = '/competition'
}
}}
/>
<div className="flex items-center justify-center pt-20" style={{ minHeight: 'calc(100vh - 80px)' }}>
<div
className="flex items-center justify-center pt-20"
style={{ minHeight: 'calc(100vh - 80px)' }}
>
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain" />
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<img
src="/icons/nofx.svg"
alt="NoFx Logo"
className="w-16 h-16 object-contain"
/>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
{t('appTitle', language)}
</h1>
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
{step === 'register' && t('registerTitle', language)}
{step === 'setup-otp' && t('setupTwoFactor', language)}
{step === 'verify-otp' && t('verifyOTP', language)}
</p>
</div>
{/* Registration Form */}
<div className="rounded-lg p-6" style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('email', language)}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('emailPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{betaMode && (
{/* Registration Form */}
<div
className="rounded-lg p-6"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
>
{step === 'register' && (
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: '#EAECEF' }}>
*
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('email', language)}
</label>
<input
type="text"
value={betaCode}
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
className="w-full px-3 py-2 rounded font-mono"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
placeholder="请输入6位内测码"
maxLength={6}
required={betaMode}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
6
</p>
</div>
)}
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{loading ? t('loading', language) : t('registerButton', language)}
</button>
</form>
)}
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
</div>
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: '#EAECEF' }}>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
{betaMode && (
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
*
</label>
<input
type="text"
value={betaCode}
onChange={(e) =>
setBetaCode(
e.target.value
.replace(/[^a-z0-9]/gi, '')
.toLowerCase()
)
}
className="w-full px-3 py-2 rounded font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder="请输入6位内测码"
maxLength={6}
required={betaMode}
/>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
6
</p>
</div>
)}
<div className="space-y-3">
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep1Title', language)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('authStep1Desc', language)}
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{loading
? t('loading', language)
: t('registerButton', language)}
</button>
</form>
)}
{step === 'setup-otp' && (
<div className="space-y-4">
<div className="text-center">
<div className="text-4xl mb-2">📱</div>
<h3
className="text-lg font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('setupTwoFactor', language)}
</h3>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('setupTwoFactorDesc', language)}
</p>
</div>
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('authStep2Desc', language)}
</p>
{qrCodeURL && (
<div className="space-y-3">
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep1Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep1Desc', language)}
</p>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep2Title', language)}
</p>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{t('authStep2Desc', language)}
</p>
{qrCodeURL && (
<div className="mt-2">
<p
className="text-xs mb-2"
style={{ color: '#848E9C' }}
>
{t('qrCodeHint', language)}
</p>
<div className="bg-white p-2 rounded text-center">
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code"
className="mx-auto"
/>
</div>
</div>
)}
<div className="mt-2">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>{t('qrCodeHint', language)}</p>
<div className="bg-white p-2 rounded text-center">
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(qrCodeURL)}`}
alt="QR Code" className="mx-auto" />
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
{t('otpSecret', language)}
</p>
<div className="flex items-center gap-2">
<code
className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--brand-light-gray)',
}}
>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('copy', language)}
</button>
</div>
</div>
)}
<div className="mt-2">
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>{t('otpSecret', language)}</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1 text-xs rounded font-mono"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--brand-light-gray)' }}>
{otpSecret}
</code>
<button
onClick={() => copyToClipboard(otpSecret)}
className="px-2 py-1 text-xs rounded"
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
>
{t('copy', language)}
</button>
</div>
</div>
<div
className="p-3 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
}}
>
<p
className="text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('authStep3Title', language)}
</p>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('authStep3Desc', language)}
</p>
</div>
</div>
<div className="p-3 rounded" style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)' }}>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('authStep3Title', language)}
</p>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
{t('authStep3Desc', language)}
</p>
</div>
</div>
<button
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{t('setupCompleteContinue', language)}
</button>
</div>
)}
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label className="block text-sm font-semibold mb-2" style={{ color: 'var(--brand-light-gray)' }}>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div className="text-sm px-3 py-2 rounded" style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{ background: 'var(--panel-bg-hover)', color: 'var(--text-secondary)' }}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
onClick={handleSetupComplete}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading ? t('loading', language) : t('completeRegistration', language)}
{t('setupCompleteContinue', language)}
</button>
</div>
</form>
)}
</div>
)}
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login');
window.dispatchEvent(new PopStateEvent('popstate'));
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
{step === 'verify-otp' && (
<form onSubmit={handleOTPVerify} className="space-y-4">
<div className="text-center mb-4">
<div className="text-4xl mb-2">🔐</div>
<p className="text-sm" style={{ color: '#848E9C' }}>
{t('enterOTPCode', language)}
<br />
{t('completeRegistrationSubtitle', language)}
</p>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('otpCode', language)}
</label>
<input
type="text"
value={otpCode}
onChange={(e) =>
setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="w-full px-3 py-2 rounded text-center text-2xl font-mono"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('otpPlaceholder', language)}
maxLength={6}
required
/>
</div>
{error && (
<div
className="text-sm px-3 py-2 rounded"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{error}
</div>
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep('setup-otp')}
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
style={{
background: 'var(--panel-bg-hover)',
color: 'var(--text-secondary)',
}}
>
{t('back', language)}
</button>
<button
type="submit"
disabled={loading || otpCode.length !== 6}
className="flex-1 px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
{loading
? t('loading', language)
: t('completeRegistration', language)}
</button>
</div>
</form>
)}
</div>
)}
{/* Login Link */}
{step === 'register' && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}
<button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
}}
className="font-semibold hover:underline transition-colors"
style={{ color: 'var(--brand-yellow)' }}
>
</button>
</p>
</div>
)}
</div>
</div>
</div>
);
)
}
+321 -139
View File
@@ -1,52 +1,52 @@
import { useState, useEffect } from 'react';
import type { AIModel, Exchange, CreateTraderRequest } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
import { t } from '../i18n/translations';
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
system_prompt_template: string;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
scan_interval_minutes: number;
trader_id?: string
trader_name: string
ai_model: string
exchange_id: string
btc_eth_leverage: number
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
system_prompt_template: string
is_cross_margin: boolean
use_coin_pool: boolean
use_oi_top: boolean
initial_balance: number
scan_interval_minutes: number
}
interface TraderConfigModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
isEditMode?: boolean;
availableModels?: AIModel[];
availableExchanges?: Exchange[];
onSave?: (data: CreateTraderRequest) => Promise<void>;
isOpen: boolean
onClose: () => void
traderData?: TraderConfigData | null
isEditMode?: boolean
availableModels?: AIModel[]
availableExchanges?: Exchange[]
onSave?: (data: CreateTraderRequest) => Promise<void>
}
export function TraderConfigModal({
isOpen,
onClose,
traderData,
export function TraderConfigModal({
isOpen,
onClose,
traderData,
isEditMode = false,
availableModels = [],
availableExchanges = [],
onSave
onSave,
}: TraderConfigModalProps) {
const { language } = useLanguage();
const { language } = useLanguage()
const [formData, setFormData] = useState<TraderConfigData>({
trader_name: '',
ai_model: '',
@@ -62,20 +62,25 @@ export function TraderConfigModal({
use_oi_top: false,
initial_balance: 1000,
scan_interval_minutes: 3,
});
const [isSaving, setIsSaving] = useState(false);
const [availableCoins, setAvailableCoins] = useState<string[]>([]);
const [selectedCoins, setSelectedCoins] = useState<string[]>([]);
const [showCoinSelector, setShowCoinSelector] = useState(false);
const [promptTemplates, setPromptTemplates] = useState<{name: string}[]>([]);
})
const [isSaving, setIsSaving] = useState(false)
const [availableCoins, setAvailableCoins] = useState<string[]>([])
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
const [showCoinSelector, setShowCoinSelector] = useState(false)
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
useEffect(() => {
if (traderData) {
setFormData(traderData);
setFormData(traderData)
// 设置已选择的币种
if (traderData.trading_symbols) {
const coins = traderData.trading_symbols.split(',').map(s => s.trim()).filter(s => s);
setSelectedCoins(coins);
const coins = traderData.trading_symbols
.split(',')
.map((s) => s.trim())
.filter((s) => s)
setSelectedCoins(coins)
}
} else if (!isEditMode) {
setFormData({
@@ -93,85 +98,135 @@ export function TraderConfigModal({
use_oi_top: false,
initial_balance: 1000,
scan_interval_minutes: 3,
});
})
}
// 确保旧数据也有默认的 system_prompt_template
if (traderData && !traderData.system_prompt_template) {
setFormData(prev => ({
setFormData((prev) => ({
...prev,
system_prompt_template: 'default'
}));
system_prompt_template: 'default',
}))
}
}, [traderData, isEditMode, availableModels, availableExchanges]);
}, [traderData, isEditMode, availableModels, availableExchanges])
// 获取系统配置中的币种列表
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch('/api/config');
const config = await response.json();
const response = await fetch('/api/config')
const config = await response.json()
if (config.default_coins) {
setAvailableCoins(config.default_coins);
setAvailableCoins(config.default_coins)
}
} catch (error) {
console.error('Failed to fetch config:', error);
console.error('Failed to fetch config:', error)
// 使用默认币种列表
setAvailableCoins(['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT', 'ADAUSDT']);
setAvailableCoins([
'BTCUSDT',
'ETHUSDT',
'SOLUSDT',
'BNBUSDT',
'XRPUSDT',
'DOGEUSDT',
'ADAUSDT',
])
}
};
fetchConfig();
}, []);
}
fetchConfig()
}, [])
// 获取系统提示词模板列表
useEffect(() => {
const fetchPromptTemplates = async () => {
try {
const response = await fetch('/api/prompt-templates');
const data = await response.json();
const response = await fetch('/api/prompt-templates')
const data = await response.json()
if (data.templates) {
setPromptTemplates(data.templates);
setPromptTemplates(data.templates)
}
} catch (error) {
console.error('Failed to fetch prompt templates:', error);
console.error('Failed to fetch prompt templates:', error)
// 使用默认模板列表
setPromptTemplates([{name: 'default'}, {name: 'aggressive'}]);
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
}
};
fetchPromptTemplates();
}, []);
}
fetchPromptTemplates()
}, [])
// 当选择的币种改变时,更新输入框
useEffect(() => {
const symbolsString = selectedCoins.join(',');
setFormData(prev => ({ ...prev, trading_symbols: symbolsString }));
}, [selectedCoins]);
const symbolsString = selectedCoins.join(',')
setFormData((prev) => ({ ...prev, trading_symbols: symbolsString }))
}, [selectedCoins])
if (!isOpen) return null;
if (!isOpen) return null
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
setFormData((prev) => ({ ...prev, [field]: value }))
// 如果是直接编辑trading_symbols,同步更新selectedCoins
if (field === 'trading_symbols') {
const coins = value.split(',').map((s: string) => s.trim()).filter((s: string) => s);
setSelectedCoins(coins);
const coins = value
.split(',')
.map((s: string) => s.trim())
.filter((s: string) => s)
setSelectedCoins(coins)
}
}
const handleCoinToggle = (coin: string) => {
setSelectedCoins((prev) => {
if (prev.includes(coin)) {
return prev.filter((c) => c !== coin)
} else {
return [...prev, coin]
}
})
}
const handleFetchCurrentBalance = async () => {
if (!isEditMode || !traderData?.trader_id) {
setBalanceFetchError('只有在编辑模式下才能获取当前余额');
return;
}
setIsFetchingBalance(true);
setBalanceFetchError('');
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/account?trader_id=${traderData.trader_id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('获取账户余额失败');
}
const data = await response.json();
// total_equity = 当前账户净值(包含未实现盈亏)
// 这应该作为新的初始余额
const currentBalance = data.total_equity || data.balance || 0;
setFormData(prev => ({ ...prev, initial_balance: currentBalance }));
// 显示成功提示
console.log('已获取当前余额:', currentBalance);
} catch (error) {
console.error('获取余额失败:', error);
setBalanceFetchError('获取余额失败,请检查网络连接');
} finally {
setIsFetchingBalance(false);
}
};
const handleCoinToggle = (coin: string) => {
setSelectedCoins(prev => {
if (prev.includes(coin)) {
return prev.filter(c => c !== coin);
} else {
return [...prev, coin];
}
});
};
const handleSave = async () => {
if (!onSave) return;
if (!onSave) return
setIsSaving(true);
setIsSaving(true)
try {
const saveData: CreateTraderRequest = {
name: formData.trader_name,
@@ -188,19 +243,19 @@ export function TraderConfigModal({
use_oi_top: formData.use_oi_top,
initial_balance: formData.initial_balance,
scan_interval_minutes: formData.scan_interval_minutes,
};
await onSave(saveData);
onClose();
}
await onSave(saveData)
onClose()
} catch (error) {
console.error('保存失败:', error);
console.error('保存失败:', error)
} finally {
setIsSaving(false);
setIsSaving(false)
}
};
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
@@ -236,24 +291,32 @@ export function TraderConfigModal({
</h3>
<div className="space-y-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input
type="text"
value={formData.trader_name}
onChange={(e) => handleInputChange('trader_name', e.target.value)}
onChange={(e) =>
handleInputChange('trader_name', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="请输入交易员名称"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">AI模型</label>
<label className="text-sm text-[#EAECEF] block mb-2">
AI模型
</label>
<select
value={formData.ai_model}
onChange={(e) => handleInputChange('ai_model', e.target.value)}
onChange={(e) =>
handleInputChange('ai_model', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableModels.map(model => (
{availableModels.map((model) => (
<option key={model.id} value={model.id}>
{getShortName(model.name || model.id).toUpperCase()}
</option>
@@ -261,15 +324,21 @@ export function TraderConfigModal({
</select>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select
value={formData.exchange_id}
onChange={(e) => handleInputChange('exchange_id', e.target.value)}
onChange={(e) =>
handleInputChange('exchange_id', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{availableExchanges.map(exchange => (
{availableExchanges.map((exchange) => (
<option key={exchange.id} value={exchange.id}>
{getShortName(exchange.name || exchange.id).toUpperCase()}
{getShortName(
exchange.name || exchange.id
).toUpperCase()}
</option>
))}
</select>
@@ -287,14 +356,16 @@ export function TraderConfigModal({
{/* 第一行:保证金模式和初始余额 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
@@ -302,10 +373,12 @@ export function TraderConfigModal({
</button>
<button
type="button"
onClick={() => handleInputChange('is_cross_margin', false)}
onClick={() =>
handleInputChange('is_cross_margin', false)
}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
!formData.is_cross_margin
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
@@ -314,32 +387,75 @@ export function TraderConfigModal({
</div>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"> ($)</label>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]">
($)
{!isEditMode && <span className="text-[#F0B90B] ml-1">*</span>}
</label>
{isEditMode && (
<button
type="button"
onClick={handleFetchCurrentBalance}
disabled={isFetchingBalance}
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
>
{isFetchingBalance ? '获取中...' : '获取当前余额'}
</button>
)}
</div>
<input
type="number"
value={formData.initial_balance}
onChange={(e) => handleInputChange('initial_balance', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'initial_balance',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="100"
step="100"
step="0.01"
/>
{!isEditMode && (
<p className="text-xs text-[#F0B90B] mt-1 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
P&L统计将会错误
</p>
)}
{isEditMode && (
<p className="text-xs text-[#848E9C] mt-1">
"获取当前余额"
</p>
)}
{balanceFetchError && (
<p className="text-xs text-red-500 mt-1">{balanceFetchError}</p>
)}
</div>
</div>
{/* 第二行:AI 扫描决策间隔 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">{t('aiScanInterval', language)}</label>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiScanInterval', language)}
</label>
<input
type="number"
value={formData.scan_interval_minutes}
onChange={(e) => handleInputChange('scan_interval_minutes', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'scan_interval_minutes',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="60"
step="1"
/>
<p className="text-xs text-gray-500 mt-1">{t('scanIntervalRecommend', language)}</p>
<p className="text-xs text-gray-500 mt-1">
{t('scanIntervalRecommend', language)}
</p>
</div>
<div></div>
</div>
@@ -347,22 +463,36 @@ export function TraderConfigModal({
{/* 第三行:杠杆设置 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">BTC/ETH </label>
<label className="text-sm text-[#EAECEF] block mb-2">
BTC/ETH
</label>
<input
type="number"
value={formData.btc_eth_leverage}
onChange={(e) => handleInputChange('btc_eth_leverage', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'btc_eth_leverage',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="125"
/>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<input
type="number"
value={formData.altcoin_leverage}
onChange={(e) => handleInputChange('altcoin_leverage', Number(e.target.value))}
onChange={(e) =>
handleInputChange(
'altcoin_leverage',
Number(e.target.value)
)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
min="1"
max="75"
@@ -373,7 +503,9 @@ export function TraderConfigModal({
{/* 第三行:交易币种 */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-[#EAECEF]"> (使)</label>
<label className="text-sm text-[#EAECEF]">
(使)
</label>
<button
type="button"
onClick={() => setShowCoinSelector(!showCoinSelector)}
@@ -385,17 +517,21 @@ export function TraderConfigModal({
<input
type="text"
value={formData.trading_symbols}
onChange={(e) => handleInputChange('trading_symbols', e.target.value)}
onChange={(e) =>
handleInputChange('trading_symbols', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
/>
{/* 币种选择器 */}
{showCoinSelector && (
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
<div className="text-xs text-[#848E9C] mb-2"></div>
<div className="text-xs text-[#848E9C] mb-2">
</div>
<div className="flex flex-wrap gap-2">
{availableCoins.map(coin => (
{availableCoins.map((coin) => (
<button
key={coin}
type="button"
@@ -426,19 +562,27 @@ export function TraderConfigModal({
<input
type="checkbox"
checked={formData.use_coin_pool}
onChange={(e) => handleInputChange('use_coin_pool', e.target.checked)}
onChange={(e) =>
handleInputChange('use_coin_pool', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 Coin Pool </label>
<label className="text-sm text-[#EAECEF]">
使 Coin Pool
</label>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.use_oi_top}
onChange={(e) => handleInputChange('use_oi_top', e.target.checked)}
onChange={(e) =>
handleInputChange('use_oi_top', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]">使 OI Top </label>
<label className="text-sm text-[#EAECEF]">
使 OI Top
</label>
</div>
</div>
</div>
@@ -451,17 +595,24 @@ export function TraderConfigModal({
<div className="space-y-4">
{/* 系统提示词模板选择 */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2"></label>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<select
value={formData.system_prompt_template}
onChange={(e) => handleInputChange('system_prompt_template', e.target.value)}
onChange={(e) =>
handleInputChange('system_prompt_template', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
>
{promptTemplates.map(template => (
{promptTemplates.map((template) => (
<option key={template.name} value={template.name}>
{template.name === 'default' ? 'Default (默认稳健)' :
template.name === 'aggressive' ? 'Aggressive (激进)' :
template.name.charAt(0).toUpperCase() + template.name.slice(1)}
{template.name === 'default'
? 'Default (默认稳健)'
: template.name === 'aggressive'
? 'Aggressive (激进)'
: template.name.charAt(0).toUpperCase() +
template.name.slice(1)}
</option>
))}
</select>
@@ -474,21 +625,47 @@ export function TraderConfigModal({
<input
type="checkbox"
checked={formData.override_base_prompt}
onChange={(e) => handleInputChange('override_base_prompt', e.target.checked)}
onChange={(e) =>
handleInputChange('override_base_prompt', e.target.checked)
}
className="w-4 h-4"
/>
<label className="text-sm text-[#EAECEF]"></label>
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1"><svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg> </span>
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>{' '}
</span>
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{formData.override_base_prompt ? '自定义提示词' : '附加提示词'}
{formData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</label>
<textarea
value={formData.custom_prompt}
onChange={(e) => handleInputChange('custom_prompt', e.target.value)}
onChange={(e) =>
handleInputChange('custom_prompt', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
placeholder={formData.override_base_prompt ? "输入完整的交易策略提示词..." : "输入额外的交易策略提示..."}
placeholder={
formData.override_base_prompt
? '输入完整的交易策略提示词...'
: '输入额外的交易策略提示...'
}
/>
</div>
</div>
@@ -506,14 +683,19 @@ export function TraderConfigModal({
{onSave && (
<button
onClick={handleSave}
disabled={isSaving || !formData.trader_name || !formData.ai_model || !formData.exchange_id}
disabled={
isSaving ||
!formData.trader_name ||
!formData.ai_model ||
!formData.exchange_id
}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? '保存中...' : (isEditMode ? '保存修改' : '创建交易员')}
{isSaving ? '保存中...' : isEditMode ? '保存修改' : '创建交易员'}
</button>
)}
</div>
</div>
</div>
);
)
}
+121 -61
View File
@@ -1,57 +1,70 @@
import { useState } from 'react';
import type { TraderConfigData } from '../types';
import { useState } from 'react'
import type { TraderConfigData } from '../types'
// 提取下划线后面的名称部分
function getShortName(fullName: string): string {
const parts = fullName.split('_');
return parts.length > 1 ? parts[parts.length - 1] : fullName;
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
interface TraderConfigViewModalProps {
isOpen: boolean;
onClose: () => void;
traderData?: TraderConfigData | null;
isOpen: boolean
onClose: () => void
traderData?: TraderConfigData | null
}
export function TraderConfigViewModal({
isOpen,
onClose,
traderData
export function TraderConfigViewModal({
isOpen,
onClose,
traderData,
}: TraderConfigViewModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null)
if (!isOpen || !traderData) return null;
if (!isOpen || !traderData) return null
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
await navigator.clipboard.writeText(text)
setCopiedField(fieldName)
setTimeout(() => setCopiedField(null), 2000)
} catch (error) {
console.error('Failed to copy:', error);
console.error('Failed to copy:', error)
}
};
}
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
const CopyButton = ({
text,
fieldName,
}: {
text: string
fieldName: string
}) => (
<button
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 px-2 py-1 text-xs rounded transition-all duration-200 hover:scale-105"
style={{
background: copiedField === fieldName ? 'rgba(14, 203, 129, 0.1)' : 'rgba(240, 185, 11, 0.1)',
background:
copiedField === fieldName
? 'rgba(14, 203, 129, 0.1)'
: 'rgba(240, 185, 11, 0.1)',
color: copiedField === fieldName ? '#0ECB81' : '#F0B90B',
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`
border: `1px solid ${copiedField === fieldName ? 'rgba(14, 203, 129, 0.3)' : 'rgba(240, 185, 11, 0.3)'}`,
}}
>
{copiedField === fieldName ? '✓ 已复制' : '📋 复制'}
</button>
);
)
const InfoRow = ({ label, value, copyable = false, fieldName = '' }: {
label: string;
value: string | number | boolean;
copyable?: boolean;
fieldName?: string;
const InfoRow = ({
label,
value,
copyable = false,
fieldName = '',
}: {
label: string
value: string | number | boolean
copyable?: boolean
fieldName?: string
}) => (
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
@@ -64,11 +77,11 @@ export function TraderConfigViewModal({
)}
</div>
</div>
);
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm">
<div
<div
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
@@ -79,9 +92,7 @@ export function TraderConfigViewModal({
<span className="text-lg">👁</span>
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
</h2>
<h2 className="text-xl font-bold text-[#EAECEF]"></h2>
<p className="text-sm text-[#848E9C] mt-1">
{traderData.trader_name}
</p>
@@ -91,9 +102,10 @@ export function TraderConfigViewModal({
{/* Running Status */}
<div
className="px-3 py-1 rounded-full text-xs font-bold flex items-center gap-1"
style={traderData.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
style={
traderData.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
<span>{traderData.is_running ? '●' : '○'}</span>
@@ -116,11 +128,30 @@ export function TraderConfigViewModal({
🤖
</h3>
<div className="space-y-3">
<InfoRow label="交易员ID" value={traderData.trader_id || ''} copyable fieldName="trader_id" />
<InfoRow label="交易员名称" value={traderData.trader_name} copyable fieldName="trader_name" />
<InfoRow label="AI模型" value={getShortName(traderData.ai_model).toUpperCase()} />
<InfoRow label="交易所" value={getShortName(traderData.exchange_id).toUpperCase()} />
<InfoRow label="初始余额" value={`$${traderData.initial_balance.toLocaleString()}`} />
<InfoRow
label="交易员ID"
value={traderData.trader_id || ''}
copyable
fieldName="trader_id"
/>
<InfoRow
label="交易员名称"
value={traderData.trader_name}
copyable
fieldName="trader_name"
/>
<InfoRow
label="AI模型"
value={getShortName(traderData.ai_model).toUpperCase()}
/>
<InfoRow
label="交易所"
value={getShortName(traderData.exchange_id).toUpperCase()}
/>
<InfoRow
label="初始余额"
value={`$${traderData.initial_balance.toLocaleString()}`}
/>
</div>
</div>
@@ -130,14 +161,23 @@ export function TraderConfigViewModal({
</h3>
<div className="space-y-3">
<InfoRow label="保证金模式" value={traderData.is_cross_margin ? '全仓' : '逐仓'} />
<InfoRow label="BTC/ETH 杠杆" value={`${traderData.btc_eth_leverage}x`} />
<InfoRow label="山寨币杠杆" value={`${traderData.altcoin_leverage}x`} />
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
copyable
fieldName="trading_symbols"
<InfoRow
label="保证金模式"
value={traderData.is_cross_margin ? '全仓' : '逐仓'}
/>
<InfoRow
label="BTC/ETH 杠杆"
value={`${traderData.btc_eth_leverage}x`}
/>
<InfoRow
label="山寨币杠杆"
value={`${traderData.altcoin_leverage}x`}
/>
<InfoRow
label="交易币种"
value={traderData.trading_symbols || '使用默认币种'}
copyable
fieldName="trading_symbols"
/>
</div>
</div>
@@ -148,7 +188,10 @@ export function TraderConfigViewModal({
📡
</h3>
<div className="space-y-3">
<InfoRow label="Coin Pool 信号" value={traderData.use_coin_pool} />
<InfoRow
label="Coin Pool 信号"
value={traderData.use_coin_pool}
/>
<InfoRow label="OI Top 信号" value={traderData.use_oi_top} />
</div>
</div>
@@ -160,29 +203,41 @@ export function TraderConfigViewModal({
💬
</h3>
{traderData.custom_prompt && (
<CopyButton text={traderData.custom_prompt} fieldName="custom_prompt" />
<CopyButton
text={traderData.custom_prompt}
fieldName="custom_prompt"
/>
)}
</div>
<div className="space-y-3">
<InfoRow label="覆盖默认提示词" value={traderData.override_base_prompt} />
<InfoRow
label="覆盖默认提示词"
value={traderData.override_base_prompt}
/>
{traderData.custom_prompt ? (
<div>
<div className="text-sm text-[#848E9C] mb-2">
{traderData.override_base_prompt ? '自定义提示词' : '附加提示词'}
{traderData.override_base_prompt
? '自定义提示词'
: '附加提示词'}
</div>
<div
<div
className="p-3 rounded border text-sm text-[#EAECEF] font-mono leading-relaxed max-h-48 overflow-y-auto"
style={{
background: '#0B0E11',
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
whiteSpace: 'pre-wrap'
whiteSpace: 'pre-wrap',
}}
>
{traderData.custom_prompt}
</div>
</div>
) : (
<div className="text-sm text-[#848E9C] italic p-3 rounded border" style={{ border: '1px solid #2B3139' }}>
<div
className="text-sm text-[#848E9C] italic p-3 rounded border"
style={{ border: '1px solid #2B3139' }}
>
使
</div>
)}
@@ -199,7 +254,12 @@ export function TraderConfigViewModal({
</button>
<button
onClick={() => copyToClipboard(JSON.stringify(traderData, null, 2), 'full_config')}
onClick={() =>
copyToClipboard(
JSON.stringify(traderData, null, 2),
'full_config'
)
}
className="px-6 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 font-medium shadow-lg"
>
{copiedField === 'full_config' ? '✓ 已复制配置' : '📋 复制完整配置'}
@@ -207,5 +267,5 @@ export function TraderConfigViewModal({
</div>
</div>
</div>
);
}
)
}
+19 -6
View File
@@ -21,16 +21,25 @@ export default function Typewriter({
const charIndexRef = useRef(0)
const timerRef = useRef<number | null>(null)
const blinkRef = useRef<number | null>(null)
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
const sanitizedLines = useMemo(
() => lines.map((l) => String(l ?? '')),
[lines]
)
useEffect(() => {
// 重置状态
lineIndexRef.current = 0
charIndexRef.current = 0
setTypedLines([''])
function typeNext() {
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
if (charIndexRef.current < currentLine.length) {
const ch = currentLine.charAt(charIndexRef.current)
setTypedLines((prev) => {
const next = [...prev]
const ch = currentLine.charAt(charIndexRef.current)
next[next.length - 1] = (next[next.length - 1] || '') + ch
const lastIndex = next.length - 1
next[lastIndex] = (next[lastIndex] ?? '') + ch
return next
})
charIndexRef.current += 1
@@ -49,7 +58,8 @@ export default function Typewriter({
}
}
typeNext()
// 延迟一帧开始打字,确保状态已重置
timerRef.current = window.setTimeout(typeNext, 0)
// 光标闪烁
blinkRef.current = window.setInterval(() => {
@@ -60,9 +70,12 @@ export default function Typewriter({
if (timerRef.current) window.clearTimeout(timerRef.current)
if (blinkRef.current) window.clearInterval(blinkRef.current)
}
}, [lines, typingSpeed, lineDelay])
}, [sanitizedLines, typingSpeed, lineDelay])
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
const displayText = useMemo(
() => typedLines.join('\n').replace(/undefined/g, ''),
[typedLines]
)
return (
<pre className={className} style={{ whiteSpace: 'pre-wrap', ...style }}>
+24 -21
View File
@@ -10,18 +10,18 @@ interface AboutSectionProps {
export default function AboutSection({ language }: AboutSectionProps) {
return (
<AnimatedSection id='about' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
<AnimatedSection id="about" backgroundColor="var(--brand-dark-gray)">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<motion.div
className='space-y-6'
className="space-y-6"
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full'
className="inline-flex items-center gap-2 px-4 py-2 rounded-full"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
@@ -29,11 +29,11 @@ export default function AboutSection({ language }: AboutSectionProps) {
whileHover={{ scale: 1.05 }}
>
<Target
className='w-4 h-4'
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className='text-sm font-semibold'
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{t('aboutNofx', language)}
@@ -41,45 +41,49 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div>
<h2
className='text-4xl font-bold'
className="text-4xl font-bold"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('whatIsNofx', language)}
</h2>
<p
className='text-lg leading-relaxed'
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{t('nofxNotAnotherBot', language)} {t('nofxDescription1', language)} {t('nofxDescription2', language)}
{t('nofxNotAnotherBot', language)}{' '}
{t('nofxDescription1', language)}{' '}
{t('nofxDescription2', language)}
</p>
<p
className='text-lg leading-relaxed'
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{t('nofxDescription3', language)} {t('nofxDescription4', language)} {t('nofxDescription5', language)}
{t('nofxDescription3', language)}{' '}
{t('nofxDescription4', language)}{' '}
{t('nofxDescription5', language)}
</p>
<motion.div
className='flex items-center gap-3 pt-4'
className="flex items-center gap-3 pt-4"
whileHover={{ x: 5 }}
>
<div
className='w-12 h-12 rounded-full flex items-center justify-center'
className="w-12 h-12 rounded-full flex items-center justify-center"
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
>
<Shield
className='w-6 h-6'
className="w-6 h-6"
style={{ color: 'var(--brand-yellow)' }}
/>
</div>
<div>
<div
className='font-semibold'
className="font-semibold"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('youFullControl', language)}
</div>
<div
className='text-sm'
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
>
{t('fullControlDesc', language)}
@@ -88,9 +92,9 @@ export default function AboutSection({ language }: AboutSectionProps) {
</motion.div>
</motion.div>
<div className='relative'>
<div className="relative">
<div
className='rounded-2xl p-8'
className="rounded-2xl p-8"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
@@ -108,7 +112,7 @@ export default function AboutSection({ language }: AboutSectionProps) {
]}
typingSpeed={70}
lineDelay={900}
className='text-sm font-mono'
className="text-sm font-mono"
style={{
color: '#00FF88',
textShadow: '0 0 8px rgba(0,255,136,0.4)',
@@ -121,4 +125,3 @@ export default function AboutSection({ language }: AboutSectionProps) {
</AnimatedSection>
)
}
@@ -17,7 +17,7 @@ export default function AnimatedSection({
<motion.section
id={id}
ref={ref}
className='py-20 px-4'
className="py-20 px-4"
style={{ background: backgroundColor }}
initial={{ opacity: 0 }}
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
@@ -27,4 +27,3 @@ export default function AnimatedSection({
</motion.section>
)
}
+28 -17
View File
@@ -2,31 +2,40 @@ import { motion } from 'framer-motion'
import AnimatedSection from './AnimatedSection'
interface CardProps {
quote: string;
authorName: string;
handle: string;
avatarUrl: string;
tweetUrl: string;
delay: number;
quote: string
authorName: string
handle: string
avatarUrl: string
tweetUrl: string
delay: number
}
function TestimonialCard({ quote, authorName, delay }: CardProps) {
return (
<motion.div
className='p-6 rounded-xl'
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.1)' }}
className="p-6 rounded-xl"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.1)',
}}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay }}
whileHover={{ scale: 1.05 }}
>
<p className='text-lg mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<p className="text-lg mb-4" style={{ color: 'var(--brand-light-gray)' }}>
"{quote}"
</p>
<div className='flex items-center gap-2'>
<div className='w-8 h-8 rounded-full' style={{ background: 'var(--binance-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full"
style={{ background: 'var(--binance-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-secondary)' }}
>
{authorName}
</span>
</div>
@@ -35,7 +44,9 @@ function TestimonialCard({ quote, authorName, delay }: CardProps) {
}
export default function CommunitySection() {
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
// 推特内容整合(保持原三列布局,超出自动换行)
const items: CardProps[] = [
@@ -74,12 +85,12 @@ export default function CommunitySection() {
return (
<AnimatedSection>
<div className='max-w-7xl mx-auto'>
<div className="max-w-7xl mx-auto">
<motion.div
className='grid md:grid-cols-3 gap-6'
className="grid md:grid-cols-3 gap-6"
variants={staggerContainer}
initial='initial'
whileInView='animate'
initial="initial"
whileInView="animate"
viewport={{ once: true }}
>
{items.map((item, idx) => (
+33 -17
View File
@@ -10,61 +10,78 @@ interface FeaturesSectionProps {
export default function FeaturesSection({ language }: FeaturesSectionProps) {
return (
<AnimatedSection id='features'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<AnimatedSection id="features">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05 }}
>
<Rocket className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
<Rocket
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{t('coreFeatures', language)}
</span>
</motion.div>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<h2
className="text-4xl font-bold mb-4"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('whyChooseNofx', language)}
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('openCommunityDriven', language)}
</p>
</motion.div>
<div className='grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto'>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
<CryptoFeatureCard
icon={<Code className='w-8 h-8' />}
icon={<Code className="w-8 h-8" />}
title={t('openSourceSelfHosted', language)}
description={t('openSourceDesc', language)}
features={[
t('openSourceFeatures1', language),
t('openSourceFeatures2', language),
t('openSourceFeatures3', language),
t('openSourceFeatures4', language)
t('openSourceFeatures4', language),
]}
delay={0}
/>
<CryptoFeatureCard
icon={<Cpu className='w-8 h-8' />}
icon={<Cpu className="w-8 h-8" />}
title={t('multiAgentCompetition', language)}
description={t('multiAgentDesc', language)}
features={[
t('multiAgentFeatures1', language),
t('multiAgentFeatures2', language),
t('multiAgentFeatures3', language),
t('multiAgentFeatures4', language)
t('multiAgentFeatures4', language),
]}
delay={0.1}
/>
<CryptoFeatureCard
icon={<Lock className='w-8 h-8' />}
icon={<Lock className="w-8 h-8" />}
title={t('secureReliableTrading', language)}
description={t('secureDesc', language)}
features={[
t('secureFeatures1', language),
t('secureFeatures2', language),
t('secureFeatures3', language),
t('secureFeatures4', language)
t('secureFeatures4', language),
]}
delay={0.2}
/>
@@ -73,4 +90,3 @@ export default function FeaturesSection({ language }: FeaturesSectionProps) {
</AnimatedSection>
)
}
+68 -57
View File
@@ -6,57 +6,62 @@ interface FooterSectionProps {
export default function FooterSection({ language }: FooterSectionProps) {
return (
<footer style={{ borderTop: '1px solid var(--panel-border)', background: 'var(--brand-dark-gray)' }}>
<div className='max-w-[1200px] mx-auto px-6 py-10'>
<footer
style={{
borderTop: '1px solid var(--panel-border)',
background: 'var(--brand-dark-gray)',
}}
>
<div className="max-w-[1200px] mx-auto px-6 py-10">
{/* Brand */}
<div className='flex items-center gap-3 mb-8'>
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
<div className="flex items-center gap-3 mb-8">
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<div>
<div className='text-lg font-bold' style={{ color: '#EAECEF' }}>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
NOFX
</div>
<div className='text-xs' style={{ color: '#848E9C' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('futureStandardAI', language)}
</div>
</div>
</div>
{/* Multi-link columns */}
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8'>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 gap-8">
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('links', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
>
GitHub
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://t.me/nofx_dev_community'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://t.me/nofx_dev_community"
target="_blank"
rel="noopener noreferrer"
>
Telegram
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://x.com/nofx_ai'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://x.com/nofx_ai"
target="_blank"
rel="noopener noreferrer"
>
X (Twitter)
</a>
@@ -66,38 +71,38 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('resources', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/blob/main/README.md'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
>
{t('documentation', language)}
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/issues'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/issues"
target="_blank"
rel="noopener noreferrer"
>
Issues
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://github.com/tinkle-community/nofx/pulls'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://github.com/tinkle-community/nofx/pulls"
target="_blank"
rel="noopener noreferrer"
>
Pull Requests
</a>
@@ -107,50 +112,53 @@ export default function FooterSection({ language }: FooterSectionProps) {
<div>
<h3
className='text-sm font-semibold mb-3'
className="text-sm font-semibold mb-3"
style={{ color: '#EAECEF' }}
>
{t('supporters', language)}
</h3>
<ul className='space-y-2 text-sm' style={{ color: '#848E9C' }}>
<ul className="space-y-2 text-sm" style={{ color: '#848E9C' }}>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://asterdex.com/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://asterdex.com/"
target="_blank"
rel="noopener noreferrer"
>
Aster DEX
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://www.binance.com/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://www.binance.com/"
target="_blank"
rel="noopener noreferrer"
>
Binance
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://hyperliquid.xyz/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://hyperliquid.xyz/"
target="_blank"
rel="noopener noreferrer"
>
Hyperliquid
</a>
</li>
<li>
<a
className='hover:text-[#F0B90B]'
href='https://amber.ac/'
target='_blank'
rel='noopener noreferrer'
className="hover:text-[#F0B90B]"
href="https://amber.ac/"
target="_blank"
rel="noopener noreferrer"
>
Amber.ac <span className='opacity-70'>{t('strategicInvestment', language)}</span>
Amber.ac{' '}
<span className="opacity-70">
{t('strategicInvestment', language)}
</span>
</a>
</li>
</ul>
@@ -159,11 +167,14 @@ export default function FooterSection({ language }: FooterSectionProps) {
{/* Bottom note (kept subtle) */}
<div
className='pt-6 mt-8 text-center text-xs'
style={{ color: 'var(--text-tertiary)', borderTop: '1px solid var(--panel-border)' }}
className="pt-6 mt-8 text-center text-xs"
style={{
color: 'var(--text-tertiary)',
borderTop: '1px solid var(--panel-border)',
}}
>
<p>{t('footerTitle', language)}</p>
<p className='mt-1'>{t('footerWarning', language)}</p>
<p className="mt-1">{t('footerWarning', language)}</p>
</div>
</div>
</footer>
+425 -223
View File
@@ -16,7 +16,17 @@ interface HeaderBarProps {
onPageChange?: (page: string) => void
}
export default function HeaderBar({ isLoggedIn = false, isHomePage = false, currentPage, language = 'zh' as Language, onLanguageChange, user, onLogout, isAdminMode = false, onPageChange }: HeaderBarProps) {
export default function HeaderBar({
isLoggedIn = false,
isHomePage = false,
currentPage,
language = 'zh' as Language,
onLanguageChange,
user,
onLogout,
isAdminMode = false,
onPageChange,
}: HeaderBarProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
@@ -26,10 +36,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setLanguageDropdownOpen(false)
}
if (userDropdownRef.current && !userDropdownRef.current.contains(event.target as Node)) {
if (
userDropdownRef.current &&
!userDropdownRef.current.contains(event.target as Node)
) {
setUserDropdownOpen(false)
}
}
@@ -41,231 +57,311 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
}, [])
return (
<nav className='fixed top-0 w-full z-50 header-bar'>
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
<div className='flex items-center justify-between h-16'>
<nav className="fixed top-0 w-full z-50 header-bar">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<a href='/' className='flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer'>
<img src='/icons/nofx.svg' alt='NOFX Logo' className='w-8 h-8' />
<span className='text-xl font-bold' style={{ color: 'var(--brand-yellow)' }}>
<a
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
>
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<span
className="text-xl font-bold"
style={{ color: 'var(--brand-yellow)' }}
>
NOFX
</span>
<span className='text-sm hidden sm:block' style={{ color: 'var(--text-secondary)' }}>
<span
className="text-sm hidden sm:block"
style={{ color: 'var(--text-secondary)' }}
>
Agentic Trading OS
</span>
</a>
{/* Desktop Menu */}
<div className='hidden md:flex items-center justify-between flex-1 ml-8'>
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
{/* Left Side - Navigation Tabs */}
<div className='flex items-center gap-4'>
<div className="flex items-center gap-4">
{isLoggedIn ? (
// Main app navigation when logged in
<>
<button
onClick={() => {
console.log('实时 button clicked, onPageChange:', onPageChange);
onPageChange?.('competition');
console.log(
'实时 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('competition')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
{/* Background for selected state */}
{currentPage === 'competition' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('realtimeNav', language)}
</button>
<button
onClick={() => {
console.log('配置 button clicked, onPageChange:', onPageChange);
onPageChange?.('traders');
console.log(
'配置 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('traders')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'traders') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
{/* Background for selected state */}
{currentPage === 'traders' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('configNav', language)}
</button>
<button
onClick={() => {
console.log('看板 button clicked, onPageChange:', onPageChange);
onPageChange?.('trader');
console.log(
'看板 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('trader')
}}
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'trader') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
{/* Background for selected state */}
{currentPage === 'trader' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('dashboardNav', language)}
</button>
</>
) : (
// Landing page navigation when not logged in
<a
href='/competition'
className='text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
href="/competition"
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-yellow)';
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'competition') {
e.currentTarget.style.color = 'var(--brand-light-gray)';
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
{/* Background for selected state */}
{currentPage === 'competition' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('realtimeNav', language)}
</a>
)}
</div>
{/* Right Side - Original Navigation Items and Login */}
<div className='flex items-center gap-6'>
<div className="flex items-center gap-6">
{/* Only show original navigation items on home page */}
{isHomePage && [
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) }
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
className='text-sm transition-colors relative group'
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
<span
className='absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300'
style={{ background: 'var(--brand-yellow)' }}
/>
</a>
))}
{isHomePage &&
[
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) },
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={
item.key === 'GitHub' || item.key === 'community'
? '_blank'
: undefined
}
rel={
item.key === 'GitHub' || item.key === 'community'
? 'noopener noreferrer'
: undefined
}
className="text-sm transition-colors relative group"
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
<span
className="absolute -bottom-1 left-0 w-0 h-0.5 group-hover:w-full transition-all duration-300"
style={{ background: 'var(--brand-yellow)' }}
/>
</a>
))}
{/* User Info and Actions */}
{isLoggedIn && user ? (
<div className='flex items-center gap-3'>
<div className="flex items-center gap-3">
{/* User Info with Dropdown */}
<div className='relative' ref={userDropdownRef}>
<div className="relative" ref={userDropdownRef}>
<button
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
style={{ background: 'var(--panel-bg)', border: '1px solid var(--panel-border)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--panel-bg)'}
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--panel-border)',
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background =
'rgba(255, 255, 255, 0.05)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = 'var(--panel-bg)')
}
>
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{user.email[0].toUpperCase()}
</div>
<span className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</span>
<ChevronDown className='w-4 h-4' style={{ color: 'var(--brand-light-gray)' }} />
<span
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</span>
<ChevronDown
className="w-4 h-4"
style={{ color: 'var(--brand-light-gray)' }}
/>
</button>
{userDropdownOpen && (
<div className='absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
<div className='px-3 py-2 border-b' style={{ borderColor: 'var(--panel-border)' }}>
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
<div className='text-sm font-medium' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
<div
className="absolute right-0 top-full mt-2 w-48 rounded-lg shadow-lg overflow-hidden z-50"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid var(--panel-border)',
}}
>
<div
className="px-3 py-2 border-b"
style={{ borderColor: 'var(--panel-border)' }}
>
<div
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('loggedInAs', language)}
</div>
<div
className="text-sm font-medium"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</div>
</div>
{!isAdminMode && onLogout && (
<button
@@ -273,10 +369,13 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout()
setUserDropdownOpen(false)
}}
className='w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center'
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
className="w-full px-3 py-2 text-sm font-semibold transition-colors hover:opacity-80 text-center"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{t('exitLogin', language)}
{t('exitLogin', language)}
</button>
)}
</div>
@@ -285,43 +384,58 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
</div>
) : (
/* Show login/register buttons when not logged in and not on login/register pages */
currentPage !== 'login' && currentPage !== 'register' && (
<div className='flex items-center gap-3'>
currentPage !== 'login' &&
currentPage !== 'register' && (
<div className="flex items-center gap-3">
<a
href='/login'
className='px-3 py-2 text-sm font-medium transition-colors rounded'
href="/login"
className="px-3 py-2 text-sm font-medium transition-colors rounded"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('signIn', language)}
{t('signIn', language)}
</a>
<a
href='/register'
className='px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('signUp', language)}
{t('signUp', language)}
</a>
</div>
)
)}
{/* Language Toggle - Always at the rightmost */}
<div className='relative' ref={dropdownRef}>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
className='flex items-center gap-2 px-3 py-2 rounded transition-colors'
className="flex items-center gap-2 px-3 py-2 rounded transition-colors"
style={{ color: 'var(--brand-light-gray)' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
onMouseEnter={(e) =>
(e.currentTarget.style.background =
'rgba(255, 255, 255, 0.05)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = 'transparent')
}
>
<span className='text-lg'>
<span className="text-lg">
{language === 'zh' ? '🇨🇳' : '🇺🇸'}
</span>
<ChevronDown className='w-4 h-4' />
<ChevronDown className="w-4 h-4" />
</button>
{languageDropdownOpen && (
<div className='absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50' style={{ background: 'var(--brand-dark-gray)', border: '1px solid var(--panel-border)' }}>
<div
className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid var(--panel-border)',
}}
>
<button
onClick={() => {
onLanguageChange?.('zh')
@@ -330,13 +444,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
language === 'zh' ? '' : 'hover:opacity-80'
}`}
style={{
style={{
color: 'var(--brand-light-gray)',
background: language === 'zh' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
background:
language === 'zh'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
>
<span className='text-base'>🇨🇳</span>
<span className='text-sm'></span>
<span className="text-base">🇨🇳</span>
<span className="text-sm"></span>
</button>
<button
onClick={() => {
@@ -346,13 +463,16 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors ${
language === 'en' ? '' : 'hover:opacity-80'
}`}
style={{
style={{
color: 'var(--brand-light-gray)',
background: language === 'en' ? 'rgba(240, 185, 11, 0.1)' : 'transparent'
background:
language === 'en'
? 'rgba(240, 185, 11, 0.1)'
: 'transparent',
}}
>
<span className='text-base'>🇺🇸</span>
<span className='text-sm'>English</span>
<span className="text-base">🇺🇸</span>
<span className="text-sm">English</span>
</button>
</div>
)}
@@ -363,11 +483,15 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu Button */}
<motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className='md:hidden'
className="md:hidden"
style={{ color: 'var(--brand-light-gray)' }}
whileTap={{ scale: 0.9 }}
>
{mobileMenuOpen ? <X className='w-6 h-6' /> : <Menu className='w-6 h-6' />}
{mobileMenuOpen ? (
<X className="w-6 h-6" />
) : (
<Menu className="w-6 h-6" />
)}
</motion.button>
</div>
</div>
@@ -375,65 +499,81 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
{/* Mobile Menu */}
<motion.div
initial={false}
animate={mobileMenuOpen ? { height: 'auto', opacity: 1 } : { height: 0, opacity: 0 }}
animate={
mobileMenuOpen
? { height: 'auto', opacity: 1 }
: { height: 0, opacity: 0 }
}
transition={{ duration: 0.3 }}
className='md:hidden overflow-hidden'
style={{ background: 'var(--brand-dark-gray)', borderTop: '1px solid rgba(240, 185, 11, 0.1)' }}
className="md:hidden overflow-hidden"
style={{
background: 'var(--brand-dark-gray)',
borderTop: '1px solid rgba(240, 185, 11, 0.1)',
}}
>
<div className='px-4 py-4 space-y-3'>
<div className="px-4 py-4 space-y-3">
{/* New Navigation Tabs */}
{isLoggedIn ? (
<button
onClick={() => {
console.log('移动端 实时 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 实时 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('competition')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
{currentPage === 'competition' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('realtimeNav', language)}
</button>
) : (
<a
href='/competition'
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500'
<a
href="/competition"
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color: currentPage === 'competition' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'competition'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative'
position: 'relative',
}}
>
{/* Background for selected state */}
{currentPage === 'competition' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('realtimeNav', language)}
</a>
)}
@@ -442,107 +582,135 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
<>
<button
onClick={() => {
console.log('移动端 配置 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 配置 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('traders')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
style={{
color: currentPage === 'traders' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'traders'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
{currentPage === 'traders' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('configNav', language)}
</button>
<button
onClick={() => {
console.log('移动端 看板 button clicked, onPageChange:', onPageChange);
console.log(
'移动端 看板 button clicked, onPageChange:',
onPageChange
)
onPageChange?.('trader')
setMobileMenuOpen(false)
}}
className='block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500'
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
style={{
color: currentPage === 'trader' ? 'var(--brand-yellow)' : 'var(--brand-light-gray)',
color:
currentPage === 'trader'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left'
textAlign: 'left',
}}
>
{/* Background for selected state */}
{currentPage === 'trader' && (
<span
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1
zIndex: -1,
}}
/>
)}
{t('dashboardNav', language)}
</button>
</>
)}
{/* Original Navigation Items - Only on home page */}
{isHomePage && [
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) }
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={item.key === 'GitHub' || item.key === 'community' ? '_blank' : undefined}
rel={item.key === 'GitHub' || item.key === 'community' ? 'noopener noreferrer' : undefined}
className='block text-sm py-2'
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
</a>
))}
{isHomePage &&
[
{ key: 'features', label: t('features', language) },
{ key: 'howItWorks', label: t('howItWorks', language) },
{ key: 'GitHub', label: 'GitHub' },
{ key: 'community', label: t('community', language) },
].map((item) => (
<a
key={item.key}
href={
item.key === 'GitHub'
? 'https://github.com/tinkle-community/nofx'
: item.key === 'community'
? 'https://t.me/nofx_dev_community'
: `#${item.key === 'features' ? 'features' : 'how-it-works'}`
}
target={
item.key === 'GitHub' || item.key === 'community'
? '_blank'
: undefined
}
rel={
item.key === 'GitHub' || item.key === 'community'
? 'noopener noreferrer'
: undefined
}
className="block text-sm py-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{item.label}
</a>
))}
{/* Language Toggle */}
<div className='py-2'>
<div className='flex items-center gap-2 mb-2'>
<span className='text-xs' style={{ color: 'var(--brand-light-gray)' }}>{t('language', language)}:</span>
<div className="py-2">
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('language', language)}:
</span>
</div>
<div className='space-y-1'>
<div className="space-y-1">
<button
onClick={() => {
onLanguageChange?.('zh')
setMobileMenuOpen(false)
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded transition-colors ${
language === 'zh' ? 'bg-yellow-500 text-black' : 'text-gray-400 hover:text-white'
language === 'zh'
? 'bg-yellow-500 text-black'
: 'text-gray-400 hover:text-white'
}`}
>
<span className='text-lg'>🇨🇳</span>
<span className='text-sm'></span>
<span className="text-lg">🇨🇳</span>
<span className="text-sm"></span>
</button>
<button
onClick={() => {
@@ -550,25 +718,49 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
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'
language === 'en'
? 'bg-yellow-500 text-black'
: 'text-gray-400 hover:text-white'
}`}
>
<span className='text-lg'>🇺🇸</span>
<span className='text-sm'>English</span>
<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' style={{ borderTop: '1px solid var(--panel-border)' }}>
<div className='flex items-center gap-2 px-3 py-2 mb-2 rounded' style={{ background: 'var(--panel-bg)' }}>
<div className='w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}>
<div
className="mt-4 pt-4"
style={{ borderTop: '1px solid var(--panel-border)' }}
>
<div
className="flex items-center gap-2 px-3 py-2 mb-2 rounded"
style={{ background: 'var(--panel-bg)' }}
>
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{user.email[0].toUpperCase()}
</div>
<div>
<div className='text-xs' style={{ color: 'var(--text-secondary)' }}>{t('loggedInAs', language)}</div>
<div className='text-sm' style={{ color: 'var(--brand-light-gray)' }}>{user.email}</div>
<div
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{t('loggedInAs', language)}
</div>
<div
className="text-sm"
style={{ color: 'var(--brand-light-gray)' }}
>
{user.email}
</div>
</div>
</div>
{!isAdminMode && onLogout && (
@@ -577,8 +769,11 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
onLogout()
setMobileMenuOpen(false)
}}
className='w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center'
style={{ background: 'var(--binance-red-bg)', color: 'var(--binance-red)' }}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-colors text-center"
style={{
background: 'var(--binance-red-bg)',
color: 'var(--binance-red)',
}}
>
{t('exitLogin', language)}
</button>
@@ -587,29 +782,36 @@ export default function HeaderBar({ isLoggedIn = false, isHomePage = false, curr
)}
{/* Show login/register buttons when not logged in and not on login/register pages */}
{!isLoggedIn && currentPage !== 'login' && currentPage !== 'register' && (
<div className='space-y-2 mt-2'>
<a
href='/login'
className='block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors'
style={{ color: 'var(--brand-light-gray)', border: '1px solid var(--brand-light-gray)' }}
onClick={() => setMobileMenuOpen(false)}
>
{t('signIn', language)}
</a>
<a
href='/register'
className='block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
</div>
)}
{!isLoggedIn &&
currentPage !== 'login' &&
currentPage !== 'register' && (
<div className="space-y-2 mt-2">
<a
href="/login"
className="block w-full px-4 py-2 rounded text-sm font-medium text-center transition-colors"
style={{
color: 'var(--brand-light-gray)',
border: '1px solid var(--brand-light-gray)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signIn', language)}
</a>
<a
href="/register"
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
</div>
)}
</div>
</motion.div>
</nav>
)
}
+124 -47
View File
@@ -1,6 +1,8 @@
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
import { Sparkles } from 'lucide-react'
import { t, Language } from '../../i18n/translations'
import { useGitHubStats } from '../../hooks/useGitHubStats'
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
interface HeroSectionProps {
language: Language
@@ -11,75 +13,151 @@ export default function HeroSection({ language }: HeroSectionProps) {
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
const handControls = useAnimation()
const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx')
// 动画数字 - 仅对 stars 添加动画
const animatedStars = useCounterAnimation({
start: 0,
end: stars,
duration: 2000,
})
const fadeInUp = {
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] },
}
const staggerContainer = { animate: { transition: { staggerChildren: 0.1 } } }
const staggerContainer = {
animate: { transition: { staggerChildren: 0.1 } },
}
return (
<section className='relative pt-32 pb-20 px-4'>
<div className='max-w-7xl mx-auto'>
<div className='grid lg:grid-cols-2 gap-12 items-center'>
<section className="relative pt-32 pb-20 px-4">
<div className="max-w-7xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Content */}
<motion.div className='space-y-6 relative z-10' style={{ opacity, scale }} initial='initial' animate='animate' variants={staggerContainer}>
<motion.div
className="space-y-6 relative z-10"
style={{ opacity, scale }}
initial="initial"
animate="animate"
variants={staggerContainer}
>
<motion.div variants={fadeInUp}>
<motion.div
className='inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6'
style={{ background: 'rgba(240, 185, 11, 0.1)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
whileHover={{ scale: 1.05, boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)' }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full mb-6"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{
scale: 1.05,
boxShadow: '0 0 20px rgba(240, 185, 11, 0.2)',
}}
>
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
{t('githubStarsInDays', language)}
<Sparkles
className="w-4 h-4"
style={{ color: 'var(--brand-yellow)' }}
/>
<span
className="text-sm font-semibold"
style={{ color: 'var(--brand-yellow)' }}
>
{isLoading ? (
t('githubStarsInDays', language)
) : language === 'zh' ? (
<>
{daysOld} {' '}
<span className="inline-block tabular-nums">
{(animatedStars / 1000).toFixed(1)}
</span>
K+ GitHub Stars
</>
) : (
<>
<span className="inline-block tabular-nums">
{(animatedStars / 1000).toFixed(1)}
</span>
K+ GitHub Stars in {daysOld} days
</>
)}
</span>
</motion.div>
</motion.div>
<h1 className='text-5xl lg:text-7xl font-bold leading-tight' style={{ color: 'var(--brand-light-gray)' }}>
<h1
className="text-5xl lg:text-7xl font-bold leading-tight"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('heroTitle1', language)}
<br />
<span style={{ color: 'var(--brand-yellow)' }}>{t('heroTitle2', language)}</span>
<span style={{ color: 'var(--brand-yellow)' }}>
{t('heroTitle2', language)}
</span>
</h1>
<motion.p className='text-xl leading-relaxed' style={{ color: 'var(--text-secondary)' }} variants={fadeInUp}>
<motion.p
className="text-xl leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
variants={fadeInUp}
>
{t('heroDescription', language)}
</motion.p>
<div className='flex items-center gap-3 flex-wrap'>
<motion.a href='https://github.com/tinkle-community/nofx' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<div className="flex items-center gap-3 flex-wrap">
<motion.a
href="https://github.com/tinkle-community/nofx"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Stars'
className='h-7'
src="https://img.shields.io/github/stars/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Stars"
className="h-7"
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/network/members' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<motion.a
href="https://github.com/tinkle-community/nofx/network/members"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Forks'
className='h-7'
src="https://img.shields.io/github/forks/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Forks"
className="h-7"
/>
</motion.a>
<motion.a href='https://github.com/tinkle-community/nofx/graphs/contributors' target='_blank' rel='noopener noreferrer' whileHover={{ scale: 1.05 }} transition={{ type: 'spring', stiffness: 400 }}>
<motion.a
href="https://github.com/tinkle-community/nofx/graphs/contributors"
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.05 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<img
src='https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A'
alt='GitHub Contributors'
className='h-7'
src="https://img.shields.io/github/contributors/tinkle-community/nofx?style=for-the-badge&logo=github&logoColor=white&color=F0B90B&labelColor=0A0A0A"
alt="GitHub Contributors"
className="h-7"
/>
</motion.a>
</div>
<motion.p className='text-xs pt-4' style={{ color: 'var(--text-tertiary)' }} variants={fadeInUp}>
{t('poweredBy', language)}
<motion.p
className="text-xs pt-4"
style={{ color: 'var(--text-tertiary)' }}
variants={fadeInUp}
>
{t('poweredBy', language)}
</motion.p>
</motion.div>
{/* Right Visual - Interactive Robot */}
<div
className='relative w-full cursor-pointer'
<div
className="relative w-full cursor-pointer"
onMouseEnter={() => {
handControls.start({
y: [-8, 8, -8],
@@ -88,9 +166,9 @@ export default function HeroSection({ language }: HeroSectionProps) {
transition: {
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.5, 1]
}
ease: 'easeInOut',
times: [0, 0.5, 1],
},
})
}}
onMouseLeave={() => {
@@ -100,32 +178,32 @@ export default function HeroSection({ language }: HeroSectionProps) {
x: 0,
transition: {
duration: 0.6,
ease: "easeOut"
}
ease: 'easeOut',
},
})
}}
>
{/* Background Layer */}
<motion.img
src='/images/hand-bg.png'
alt='NOFX Platform Background'
className='w-full opacity-90'
<motion.img
src="/images/hand-bg.png"
alt="NOFX Platform Background"
className="w-full opacity-90"
style={{ opacity, scale }}
whileHover={{ scale: 1.02 }}
transition={{ type: 'spring', stiffness: 300 }}
/>
{/* Hand Layer - Animated */}
<motion.img
src='/images/hand.png'
alt='Robot Hand'
className='absolute top-0 left-0 w-full'
<motion.img
src="/images/hand.png"
alt="Robot Hand"
className="absolute top-0 left-0 w-full"
style={{ opacity }}
animate={handControls}
initial={{ y: 0, rotate: 0, x: 0 }}
whileHover={{
scale: 1.05,
transition: { type: 'spring', stiffness: 400 }
transition: { type: 'spring', stiffness: 400 },
}}
/>
</div>
@@ -134,4 +212,3 @@ export default function HeroSection({ language }: HeroSectionProps) {
</section>
)
}
@@ -4,20 +4,36 @@ import { t, Language } from '../../i18n/translations'
function StepCard({ number, title, description, delay }: any) {
return (
<motion.div className='flex gap-6 items-start' initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, x: 0 }} viewport={{ once: true }} transition={{ delay }} whileHover={{ x: 10 }}>
<motion.div
className="flex gap-6 items-start"
initial={{ opacity: 0, x: -50 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay }}
whileHover={{ x: 10 }}
>
<motion.div
className='flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl'
style={{ background: 'var(--binance-yellow)', color: 'var(--brand-black)' }}
className="flex-shrink-0 w-14 h-14 rounded-full flex items-center justify-center font-bold text-2xl"
style={{
background: 'var(--binance-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{ scale: 1.2, rotate: 360 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
>
{number}
</motion.div>
<div>
<h3 className='text-2xl font-semibold mb-2' style={{ color: 'var(--brand-light-gray)' }}>
<h3
className="text-2xl font-semibold mb-2"
style={{ color: 'var(--brand-light-gray)' }}
>
{title}
</h3>
<p className='text-lg leading-relaxed' style={{ color: 'var(--text-secondary)' }}>
<p
className="text-lg leading-relaxed"
style={{ color: 'var(--text-secondary)' }}
>
{description}
</p>
</div>
@@ -29,46 +45,91 @@ interface HowItWorksSectionProps {
language: Language
}
export default function HowItWorksSection({ language }: HowItWorksSectionProps) {
export default function HowItWorksSection({
language,
}: HowItWorksSectionProps) {
return (
<AnimatedSection id='how-it-works' backgroundColor='var(--brand-dark-gray)'>
<div className='max-w-7xl mx-auto'>
<motion.div className='text-center mb-16' initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<h2 className='text-4xl font-bold mb-4' style={{ color: 'var(--brand-light-gray)' }}>
<AnimatedSection id="how-it-works" backgroundColor="var(--brand-dark-gray)">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
<h2
className="text-4xl font-bold mb-4"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('howToStart', language)}
</h2>
<p className='text-lg' style={{ color: 'var(--text-secondary)' }}>
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
{t('fourSimpleSteps', language)}
</p>
</motion.div>
<div className='space-y-8'>
<div className="space-y-8">
{[
{ number: 1, title: t('step1Title', language), description: t('step1Desc', language) },
{ number: 2, title: t('step2Title', language), description: t('step2Desc', language) },
{ number: 3, title: t('step3Title', language), description: t('step3Desc', language) },
{ number: 4, title: t('step4Title', language), description: t('step4Desc', language) },
{
number: 1,
title: t('step1Title', language),
description: t('step1Desc', language),
},
{
number: 2,
title: t('step2Title', language),
description: t('step2Desc', language),
},
{
number: 3,
title: t('step3Title', language),
description: t('step3Desc', language),
},
{
number: 4,
title: t('step4Title', language),
description: t('step4Desc', language),
},
].map((step, index) => (
<StepCard key={step.number} {...step} delay={index * 0.1} />
))}
</div>
<motion.div
className='mt-12 p-6 rounded-xl flex items-start gap-4'
style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}
className="mt-12 p-6 rounded-xl flex items-start gap-4"
style={{
background: 'rgba(246, 70, 93, 0.1)',
border: '1px solid rgba(246, 70, 93, 0.3)',
}}
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className='w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0' style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}>
<svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><path d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z'/><line x1='12' x2='12' y1='9' y2='13'/><line x1='12' x2='12.01' y1='17' y2='17'/></svg>
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{ background: 'rgba(246, 70, 93, 0.2)', color: '#F6465D' }}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
<line x1="12" x2="12" y1="9" y2="13" />
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
</div>
<div>
<div className='font-semibold mb-2' style={{ color: '#F6465D' }}>
<div className="font-semibold mb-2" style={{ color: '#F6465D' }}>
{t('importantRiskWarning', language)}
</div>
<p className='text-sm' style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{t('riskWarningText', language)}
</p>
</div>
+35 -14
View File
@@ -10,7 +10,7 @@ interface LoginModalProps {
export default function LoginModal({ onClose, language }: LoginModalProps) {
return (
<motion.div
className='fixed inset-0 z-50 flex items-center justify-center p-4'
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: 'rgba(0, 0, 0, 0.8)' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -18,32 +18,50 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
onClick={onClose}
>
<motion.div
className='relative max-w-md w-full rounded-2xl p-8'
style={{ background: 'var(--brand-dark-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="relative max-w-md w-full rounded-2xl p-8"
style={{
background: 'var(--brand-dark-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
initial={{ scale: 0.9, y: 50 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 50 }}
onClick={(e) => e.stopPropagation()}
>
<motion.button onClick={onClose} className='absolute top-4 right-4' style={{ color: 'var(--text-secondary)' }} whileHover={{ scale: 1.1, rotate: 90 }} whileTap={{ scale: 0.9 }}>
<X className='w-6 h-6' />
<motion.button
onClick={onClose}
className="absolute top-4 right-4"
style={{ color: 'var(--text-secondary)' }}
whileHover={{ scale: 1.1, rotate: 90 }}
whileTap={{ scale: 0.9 }}
>
<X className="w-6 h-6" />
</motion.button>
<h2 className='text-2xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }}>
<h2
className="text-2xl font-bold mb-6"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('accessNofxPlatform', language)}
</h2>
<p className='text-sm mb-6' style={{ color: 'var(--text-secondary)' }}>
<p className="text-sm mb-6" style={{ color: 'var(--text-secondary)' }}>
{t('loginRegisterPrompt', language)}
</p>
<div className='space-y-3'>
<div className="space-y-3">
<motion.button
onClick={() => {
window.history.pushState({}, '', '/login')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }}
whileHover={{ scale: 1.05, boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)' }}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{
scale: 1.05,
boxShadow: '0 10px 30px rgba(240, 185, 11, 0.4)',
}}
whileTap={{ scale: 0.95 }}
>
{t('signIn', language)}
@@ -54,8 +72,12 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className='block w-full px-6 py-3 rounded-lg font-semibold text-center'
style={{ background: 'var(--brand-dark-gray)', color: 'var(--brand-light-gray)', border: '1px solid rgba(240, 185, 11, 0.2)' }}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-dark-gray)',
color: 'var(--brand-light-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }}
>
@@ -66,4 +88,3 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
</motion.div>
)
}
+125 -93
View File
@@ -1,62 +1,86 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { getSystemConfig } from '../lib/config';
import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config'
interface User {
id: string;
email: string;
id: string
email: string
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<{ success: boolean; message?: string; userID?: string; requiresOTP?: boolean }>;
register: (email: string, password: string, betaCode?: string) => Promise<{ success: boolean; message?: string; userID?: string; otpSecret?: string; qrCodeURL?: string }>;
verifyOTP: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
completeRegistration: (userID: string, otpCode: string) => Promise<{ success: boolean; message?: string }>;
logout: () => void;
isLoading: boolean;
user: User | null
token: string | null
login: (
email: string,
password: string
) => Promise<{
success: boolean
message?: string
userID?: string
requiresOTP?: boolean
}>
register: (
email: string,
password: string,
betaCode?: string
) => Promise<{
success: boolean
message?: string
userID?: string
otpSecret?: string
qrCodeURL?: string
}>
verifyOTP: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
completeRegistration: (
userID: string,
otpCode: string
) => Promise<{ success: boolean; message?: string }>
logout: () => void
isLoading: boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig()
.then(data => {
.then((data) => {
if (data.admin_mode) {
// 管理员模式下,模拟admin用户
setUser({ id: 'admin', email: 'admin@localhost' });
setToken('admin-mode');
setUser({ id: 'admin', email: 'admin@localhost' })
setToken('admin-mode')
} else {
// 非管理员模式,检查本地存储中是否有token
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
}
setIsLoading(false);
setIsLoading(false)
})
.catch(err => {
console.error('Failed to fetch system config:', err);
.catch((err) => {
console.error('Failed to fetch system config:', err)
// 发生错误时,继续检查本地存储
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('auth_user');
const savedToken = localStorage.getItem('auth_token')
const savedUser = localStorage.getItem('auth_user')
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
setToken(savedToken)
setUser(JSON.parse(savedUser))
}
setIsLoading(false);
});
}, []);
setIsLoading(false)
})
}, [])
const login = async (email: string, password: string) => {
try {
@@ -66,9 +90,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
if (data.requires_otp) {
@@ -77,23 +101,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
userID: data.user_id,
requiresOTP: true,
message: data.message,
};
}
}
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '登录失败,请重试' };
return { success: false, message: '登录失败,请重试' }
}
return { success: false, message: '未知错误' };
};
return { success: false, message: '未知错误' }
}
const register = async (email: string, password: string, betaCode?: string) => {
const register = async (
email: string,
password: string,
betaCode?: string
) => {
try {
const requestBody: { email: string; password: string; beta_code?: string } = { email, password };
const requestBody: {
email: string
password: string
beta_code?: string
} = { email, password }
if (betaCode) {
requestBody.beta_code = betaCode;
requestBody.beta_code = betaCode
}
const response = await fetch('/api/register', {
@@ -102,9 +134,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
return {
@@ -113,14 +145,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
otpSecret: data.otp_secret,
qrCodeURL: data.qr_code_url,
message: data.message,
};
}
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册失败,请重试' };
return { success: false, message: '注册失败,请重试' }
}
};
}
const verifyOTP = async (userID: string, otpCode: string) => {
try {
@@ -130,30 +162,30 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
// 登录成功,保存token和用户信息
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
// 跳转到首页
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
return { success: true, message: data.message };
const userInfo = { id: data.user_id, email: data.email }
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
return { success: true, message: data.message }
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: 'OTP验证失败,请重试' };
return { success: false, message: 'OTP验证失败,请重试' }
}
};
}
const completeRegistration = async (userID: string, otpCode: string) => {
try {
@@ -163,37 +195,37 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
'Content-Type': 'application/json',
},
body: JSON.stringify({ user_id: userID, otp_code: otpCode }),
});
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
// 注册完成,自动登录
const userInfo = { id: data.user_id, email: data.email };
setToken(data.token);
setUser(userInfo);
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(userInfo));
// 跳转到首页
window.history.pushState({}, '', '/');
window.dispatchEvent(new PopStateEvent('popstate'));
return { success: true, message: data.message };
const userInfo = { id: data.user_id, email: data.email }
setToken(data.token)
setUser(userInfo)
localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 跳转到配置页面
window.history.pushState({}, '', '/traders')
window.dispatchEvent(new PopStateEvent('popstate'))
return { success: true, message: data.message }
} else {
return { success: false, message: data.error };
return { success: false, message: data.error }
}
} catch (error) {
return { success: false, message: '注册完成失败,请重试' };
return { success: false, message: '注册完成失败,请重试' }
}
};
}
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
};
setUser(null)
setToken(null)
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
}
return (
<AuthContext.Provider
@@ -210,13 +242,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
>
{children}
</AuthContext.Provider>
);
)
}
export function useAuth() {
const context = useContext(AuthContext);
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
throw new Error('useAuth must be used within an AuthProvider')
}
return context;
}
return context
}
+20 -16
View File
@@ -1,37 +1,41 @@
import { createContext, useContext, useState, ReactNode } from 'react';
import type { Language } from '../i18n/translations';
import { createContext, useContext, useState, ReactNode } from 'react'
import type { Language } from '../i18n/translations'
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
language: Language
setLanguage: (lang: Language) => void
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const LanguageContext = createContext<LanguageContextType | undefined>(
undefined
)
export function LanguageProvider({ children }: { children: ReactNode }) {
// Initialize language from localStorage or default to English
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('language');
return (saved === 'en' || saved === 'zh') ? saved : 'en';
});
const saved = localStorage.getItem('language')
return saved === 'en' || saved === 'zh' ? saved : 'en'
})
// Save language to localStorage whenever it changes
const handleSetLanguage = (lang: Language) => {
setLanguage(lang);
localStorage.setItem('language', lang);
};
setLanguage(lang)
localStorage.setItem('language', lang)
}
return (
<LanguageContext.Provider value={{ language, setLanguage: handleSetLanguage }}>
<LanguageContext.Provider
value={{ language, setLanguage: handleSetLanguage }}
>
{children}
</LanguageContext.Provider>
);
)
}
export function useLanguage() {
const context = useContext(LanguageContext);
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within LanguageProvider');
throw new Error('useLanguage must be used within LanguageProvider')
}
return context;
return context
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react'
interface UseCounterAnimationOptions {
start?: number
end: number
duration?: number
decimals?: number
}
export function useCounterAnimation({
start = 0,
end,
duration = 2000,
decimals = 0,
}: UseCounterAnimationOptions): number {
const [count, setCount] = useState(start)
useEffect(() => {
if (end === 0) return
let startTime: number | null = null
let animationFrame: number
const animate = (currentTime: number) => {
if (startTime === null) startTime = currentTime
const progress = Math.min((currentTime - startTime) / duration, 1)
// 使用 easeOutExpo 缓动函数,让数字快速启动后缓慢停止
const easeOutExpo = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
const currentCount = start + (end - start) * easeOutExpo
setCount(currentCount)
if (progress < 1) {
animationFrame = requestAnimationFrame(animate)
} else {
setCount(end)
}
}
animationFrame = requestAnimationFrame(animate)
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
}
}
}, [start, end, duration])
return decimals > 0 ? parseFloat(count.toFixed(decimals)) : Math.floor(count)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
interface GitHubStats {
stars: number
forks: number
createdAt: string
daysOld: number
isLoading: boolean
error: string | null
}
export function useGitHubStats(owner: string, repo: string): GitHubStats {
const [stats, setStats] = useState<GitHubStats>({
stars: 0,
forks: 0,
createdAt: '',
daysOld: 0,
isLoading: true,
error: null,
})
useEffect(() => {
const fetchGitHubStats = async () => {
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}`
)
if (!response.ok) {
throw new Error('Failed to fetch GitHub stats')
}
const data = await response.json()
// Calculate days since creation
const createdDate = new Date(data.created_at)
const now = new Date()
const diffTime = Math.abs(now.getTime() - createdDate.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
setStats({
stars: data.stargazers_count,
forks: data.forks_count,
createdAt: data.created_at,
daysOld: diffDays,
isLoading: false,
error: null,
})
} catch (error) {
console.error('Error fetching GitHub stats:', error)
setStats((prev) => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error',
}))
}
}
fetchGitHubStats()
}, [owner, repo])
return stats
}
+19 -19
View File
@@ -1,29 +1,29 @@
import { useEffect, useState } from 'react';
import { getSystemConfig, type SystemConfig } from '../lib/config';
import { useEffect, useState } from 'react'
import { getSystemConfig, type SystemConfig } from '../lib/config'
export function useSystemConfig() {
const [config, setConfig] = useState<SystemConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<SystemConfig | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true;
let mounted = true
getSystemConfig()
.then((data) => {
if (!mounted) return;
setConfig(data);
setLoading(false);
if (!mounted) return
setConfig(data)
setLoading(false)
})
.catch((err: Error) => {
if (!mounted) return;
console.error('Failed to fetch system config:', err);
setError(err.message);
setLoading(false);
});
if (!mounted) return
console.error('Failed to fetch system config:', err)
setError(err.message)
setLoading(false)
})
return () => {
mounted = false;
};
}, []);
mounted = false
}
}, [])
return { config, loading, error };
}
return { config, loading, error }
}
+174 -96
View File
@@ -1,4 +1,4 @@
export type Language = 'en' | 'zh';
export type Language = 'en' | 'zh'
export const translations = {
en: {
@@ -15,7 +15,7 @@ export const translations = {
logout: 'Logout',
switchTrader: 'Switch Trader:',
view: 'View',
// Navigation
realtimeNav: 'Live',
configNav: 'Config',
@@ -74,7 +74,7 @@ export const translations = {
recent: 'Recent',
allData: 'All Data',
cycles: 'Cycles',
// Comparison Chart
comparisonMode: 'Comparison Mode',
dataPoints: 'Data Points',
@@ -147,7 +147,8 @@ export const translations = {
createFirstTrader: 'Create your first AI trader to get started',
configureModelsFirst: 'Please configure AI models first',
configureExchangesFirst: 'Please configure exchanges first',
configureModelsAndExchangesFirst: 'Please configure AI models and exchanges first',
configureModelsAndExchangesFirst:
'Please configure AI models and exchanges first',
modelNotConfigured: 'Selected model is not configured',
exchangeNotConfigured: 'Selected exchange is not configured',
confirmDeleteTrader: 'Are you sure you want to delete this trader?',
@@ -168,7 +169,7 @@ export const translations = {
useTestnet: 'Use Testnet',
enabled: 'Enabled',
save: 'Save',
// AI Model Configuration
officialAPI: 'Official API',
customAPI: 'Custom API',
@@ -192,9 +193,20 @@ export const translations = {
enterSigner: 'Enter Signer Address',
enterSecretKey: 'Enter Secret Key',
enterPassphrase: 'Enter Passphrase (Required for OKX)',
hyperliquidPrivateKeyDesc: 'Hyperliquid uses private key for trading authentication',
hyperliquidWalletAddressDesc: 'Wallet address corresponding to the private key',
testnetDescription: 'Enable to connect to exchange test environment for simulated trading',
hyperliquidPrivateKeyDesc:
'Hyperliquid uses private key for trading authentication',
hyperliquidWalletAddressDesc:
'Wallet address corresponding to the private key',
asterUserDesc:
'Main wallet address - The EVM wallet address you use to log in to Aster',
asterSignerDesc:
'API wallet address - Generate from https://www.asterdex.com/en/api-wallet',
asterPrivateKeyDesc:
'API wallet private key - Get from https://www.asterdex.com/en/api-wallet (only used locally for signing, never transmitted)',
asterUsdtWarning:
'Important: Aster only tracks USDT balance. Please ensure you use USDT as margin currency to avoid P&L calculation errors caused by price fluctuations of other assets (BNB, ETH, etc.)',
testnetDescription:
'Enable to connect to exchange test environment for simulated trading',
securityWarning: 'Security Warning',
saveConfiguration: 'Save Configuration',
@@ -202,20 +214,25 @@ export const translations = {
positionMode: 'Position Mode',
crossMarginMode: 'Cross Margin',
isolatedMarginMode: 'Isolated Margin',
crossMarginDescription: 'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription: 'Isolated margin: Each position manages collateral independently, risk isolation',
crossMarginDescription:
'Cross margin: All positions share account balance as collateral',
isolatedMarginDescription:
'Isolated margin: Each position manages collateral independently, risk isolation',
leverageConfiguration: 'Leverage Configuration',
btcEthLeverage: 'BTC/ETH Leverage',
altcoinLeverage: 'Altcoin Leverage',
leverageRecommendation: 'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
leverageRecommendation:
'Recommended: BTC/ETH 5-10x, Altcoins 3-5x for risk control',
tradingSymbols: 'Trading Symbols',
tradingSymbolsPlaceholder: 'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
tradingSymbolsPlaceholder:
'Enter symbols, comma separated (e.g., BTCUSDT,ETHUSDT,SOLUSDT)',
selectSymbols: 'Select Symbols',
selectTradingSymbols: 'Select Trading Symbols',
selectedSymbolsCount: 'Selected {count} symbols',
clearSelection: 'Clear All',
confirmSelection: 'Confirm',
tradingSymbolsDescription: 'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
tradingSymbolsDescription:
'Empty = use default symbols. Must end with USDT (e.g., BTCUSDT, ETHUSDT)',
btcEthLeverageValidation: 'BTC/ETH leverage must be between 1-50x',
altcoinLeverageValidation: 'Altcoin leverage must be between 1-20x',
invalidSymbolFormat: 'Invalid symbol format: {symbol}, must end with USDT',
@@ -223,7 +240,8 @@ export const translations = {
// Loading & Error
loading: 'Loading...',
loadingError: '⚠️ Failed to load AI learning data',
noCompleteData: 'No complete trading data (needs to complete open → close cycle)',
noCompleteData:
'No complete trading data (needs to complete open → close cycle)',
// AI Traders Page - Additional
inUse: 'In Use',
@@ -231,31 +249,44 @@ export const translations = {
noExchangesConfigured: 'No configured exchanges',
signalSource: 'Signal Source',
signalSourceConfig: 'Signal Source Configuration',
coinPoolDescription: 'API endpoint for coin pool data, leave blank to disable this signal source',
oiTopDescription: 'API endpoint for open interest rankings, leave blank to disable this signal source',
coinPoolDescription:
'API endpoint for coin pool data, leave blank to disable this signal source',
oiTopDescription:
'API endpoint for open interest rankings, leave blank to disable this signal source',
information: 'Information',
signalSourceInfo1: '• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo2: '• When creating traders, you can choose whether to use these signal sources',
signalSourceInfo3: '• Configured URLs will be used to fetch market data and trading signals',
signalSourceInfo1:
'• Signal source configuration is per-user, each user can set their own URLs',
signalSourceInfo2:
'• When creating traders, you can choose whether to use these signal sources',
signalSourceInfo3:
'• Configured URLs will be used to fetch market data and trading signals',
editAIModel: 'Edit AI Model',
addAIModel: 'Add AI Model',
confirmDeleteModel: 'Are you sure you want to delete this AI model configuration?',
confirmDeleteModel:
'Are you sure you want to delete this AI model configuration?',
selectModel: 'Select AI Model',
pleaseSelectModel: 'Please select a model',
customBaseURL: 'Base URL (Optional)',
customBaseURLPlaceholder: 'Custom API base URL, e.g.: https://api.openai.com/v1',
customBaseURLPlaceholder:
'Custom API base URL, e.g.: https://api.openai.com/v1',
leaveBlankForDefault: 'Leave blank to use default API address',
modelConfigInfo1: '• API Key will be encrypted and stored, please ensure it is valid',
modelConfigInfo1:
'• API Key will be encrypted and stored, please ensure it is valid',
modelConfigInfo2: '• Base URL is used for custom API server address',
modelConfigInfo3: '• After deleting configuration, traders using this model will not work properly',
modelConfigInfo3:
'• After deleting configuration, traders using this model will not work properly',
saveConfig: 'Save Configuration',
editExchange: 'Edit Exchange',
addExchange: 'Add Exchange',
confirmDeleteExchange: 'Are you sure you want to delete this exchange configuration?',
confirmDeleteExchange:
'Are you sure you want to delete this exchange configuration?',
pleaseSelectExchange: 'Please select an exchange',
exchangeConfigWarning1: '• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning2: '• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3: '• After deleting configuration, related traders will not be able to trade',
exchangeConfigWarning1:
'• API keys will be encrypted, recommend using read-only or futures trading permissions',
exchangeConfigWarning2:
'• Do not grant withdrawal permissions to ensure fund security',
exchangeConfigWarning3:
'• After deleting configuration, related traders will not be able to trade',
edit: 'Edit',
viewGuide: 'View Guide',
binanceSetupGuide: 'Binance Setup Guide',
@@ -265,7 +296,8 @@ export const translations = {
createTraderFailed: 'Failed to create trader',
getTraderConfigFailed: 'Failed to get trader configuration',
modelConfigNotExist: 'Model configuration does not exist or is not enabled',
exchangeConfigNotExist: 'Exchange configuration does not exist or is not enabled',
exchangeConfigNotExist:
'Exchange configuration does not exist or is not enabled',
updateTraderFailed: 'Failed to update trader',
deleteTraderFailed: 'Failed to delete trader',
operationFailed: 'Operation failed',
@@ -275,7 +307,7 @@ export const translations = {
exchangeNotExist: 'Exchange does not exist',
deleteExchangeConfigFailed: 'Failed to delete exchange configuration',
saveSignalSourceFailed: 'Failed to save signal source configuration',
// Login & Register
login: 'Sign In',
register: 'Sign Up',
@@ -302,12 +334,15 @@ export const translations = {
enterOTPCode: 'Enter 6-digit OTP code',
verifyOTP: 'Verify OTP',
setupTwoFactor: 'Set up two-factor authentication',
setupTwoFactorDesc: 'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions: 'Scan this QR code with Google Authenticator or Authy',
setupTwoFactorDesc:
'Follow the steps below to secure your account with Google Authenticator',
scanQRCodeInstructions:
'Scan this QR code with Google Authenticator or Authy',
otpSecret: 'Or enter this secret manually:',
qrCodeHint: 'QR code (if scanning fails, use the secret below):',
authStep1Title: 'Step 1: Install Google Authenticator',
authStep1Desc: 'Download and install Google Authenticator from your app store',
authStep1Desc:
'Download and install Google Authenticator from your app store',
authStep2Title: 'Step 2: Add account',
authStep2Desc: 'Tap "+", then choose "Scan QR code" or "Enter a setup key"',
authStep3Title: 'Step 3: Verify setup',
@@ -337,74 +372,93 @@ export const translations = {
exitLogin: 'Sign Out',
signIn: 'Sign In',
signUp: 'Sign Up',
// Hero Section
githubStarsInDays: '2.5K+ GitHub Stars in 3 days',
heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.',
heroDescription: 'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
poweredBy: 'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
heroDescription:
'NOFX is the future standard for AI trading — an open, community-driven agentic trading OS. Supporting Binance, Aster DEX and other exchanges, self-hosted, multi-agent competition, let AI automatically make decisions, execute and optimize trades for you.',
poweredBy:
'Powered by Aster DEX and Binance, strategically invested by Amber.ac.',
// Landing Page CTA
readyToDefine: 'Ready to define the future of AI trading?',
startWithCrypto: 'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
startWithCrypto:
'Starting with crypto markets, expanding to TradFi. NOFX is the infrastructure of AgentFi.',
getStartedNow: 'Get Started Now',
viewSourceCode: 'View Source Code',
// Features Section
coreFeatures: 'Core Features',
whyChooseNofx: 'Why Choose NOFX?',
openCommunityDriven: 'Open source, transparent, community-driven AI trading OS',
openCommunityDriven:
'Open source, transparent, community-driven AI trading OS',
openSourceSelfHosted: '100% Open Source & Self-Hosted',
openSourceDesc: 'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
openSourceDesc:
'Your framework, your rules. Non-black box, supports custom prompts and multi-models.',
openSourceFeatures1: 'Fully open source code',
openSourceFeatures2: 'Self-hosting deployment support',
openSourceFeatures3: 'Custom AI prompts',
openSourceFeatures4: 'Multi-model support (DeepSeek, Qwen)',
multiAgentCompetition: 'Multi-Agent Intelligent Competition',
multiAgentDesc: 'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
multiAgentDesc:
'AI strategies battle at high speed in sandbox, survival of the fittest, achieving strategy evolution.',
multiAgentFeatures1: 'Multiple AI agents running in parallel',
multiAgentFeatures2: 'Automatic strategy optimization',
multiAgentFeatures3: 'Sandbox security testing',
multiAgentFeatures4: 'Cross-market strategy porting',
secureReliableTrading: 'Secure and Reliable Trading',
secureDesc: 'Enterprise-grade security, complete control over your funds and trading strategies.',
secureDesc:
'Enterprise-grade security, complete control over your funds and trading strategies.',
secureFeatures1: 'Local private key management',
secureFeatures2: 'Fine-grained API permission control',
secureFeatures3: 'Real-time risk monitoring',
secureFeatures4: 'Trading log auditing',
// About Section
aboutNofx: 'About NOFX',
whatIsNofx: 'What is NOFX?',
nofxNotAnotherBot: "NOFX is not another trading bot, but the 'Linux' of AI trading —",
nofxDescription1: 'a transparent, trustworthy open source OS that provides a unified',
nofxDescription2: "'decision-risk-execution' layer, supporting all asset classes.",
nofxDescription3: 'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
nofxDescription4: 'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
nofxDescription5: 'flywheel (developers get point rewards for PR contributions).',
nofxNotAnotherBot:
"NOFX is not another trading bot, but the 'Linux' of AI trading —",
nofxDescription1:
'a transparent, trustworthy open source OS that provides a unified',
nofxDescription2:
"'decision-risk-execution' layer, supporting all asset classes.",
nofxDescription3:
'Starting with crypto markets (24/7, high volatility perfect testing ground), future expansion to stocks, futures, forex. Core: open architecture, AI',
nofxDescription4:
'Darwinism (multi-agent self-competition, strategy evolution), CodeFi',
nofxDescription5:
'flywheel (developers get point rewards for PR contributions).',
youFullControl: 'You 100% Control',
fullControlDesc: 'Complete control over AI prompts and funds',
startupMessages1: 'Starting automated trading system...',
startupMessages2: 'API server started on port 8080',
startupMessages3: 'Web console http://localhost:3000',
// How It Works Section
howToStart: 'How to Get Started with NOFX',
fourSimpleSteps: 'Four simple steps to start your AI automated trading journey',
fourSimpleSteps:
'Four simple steps to start your AI automated trading journey',
step1Title: 'Clone GitHub Repository',
step1Desc: 'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
step1Desc:
'git clone https://github.com/tinkle-community/nofx and switch to dev branch to test new features.',
step2Title: 'Configure Environment',
step2Desc: 'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
step2Desc:
'Frontend setup for exchange APIs (like Binance, Hyperliquid), AI models and custom prompts.',
step3Title: 'Deploy & Run',
step3Desc: 'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
step3Desc:
'One-click Docker deployment, start AI agents. Note: High-risk market, only test with money you can afford to lose.',
step4Title: 'Optimize & Contribute',
step4Desc: 'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
step4Desc:
'Monitor trading, submit PRs to improve framework. Join Telegram to share strategies.',
importantRiskWarning: 'Important Risk Warning',
riskWarningText: 'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
riskWarningText:
'Dev branch is unstable, do not use funds you cannot afford to lose. NOFX is non-custodial, no official strategies. Trading involves risks, invest carefully.',
// Community Section (testimonials are kept as-is since they are quotes)
// Footer Section
futureStandardAI: 'The future standard of AI trading',
links: 'Links',
@@ -412,10 +466,11 @@ export const translations = {
documentation: 'Documentation',
supporters: 'Supporters',
strategicInvestment: '(Strategic Investment)',
// Login Modal
accessNofxPlatform: 'Access NOFX Platform',
loginRegisterPrompt: 'Please login or register to access the full AI trading platform',
loginRegisterPrompt:
'Please login or register to access the full AI trading platform',
registerNewAccount: 'Register New Account',
},
zh: {
@@ -432,7 +487,7 @@ export const translations = {
logout: '退出',
switchTrader: '切换交易员:',
view: '查看',
// Navigation
realtimeNav: '实时',
configNav: '配置',
@@ -491,7 +546,7 @@ export const translations = {
recent: '最近',
allData: '全部数据',
cycles: '个',
// Comparison Chart
comparisonMode: '对比模式',
dataPoints: '数据点数',
@@ -585,7 +640,7 @@ export const translations = {
useTestnet: '使用测试网',
enabled: '启用',
save: '保存',
// AI Model Configuration
officialAPI: '官方API',
customAPI: '自定义API',
@@ -611,7 +666,15 @@ export const translations = {
enterPassphrase: '输入Passphrase (OKX必填)',
hyperliquidPrivateKeyDesc: 'Hyperliquid 使用私钥进行交易认证',
hyperliquidWalletAddressDesc: '与私钥对应的钱包地址',
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
asterUserDesc:
'主钱包地址 - 您用于登录 Aster 的 EVM 钱包地址',
asterSignerDesc:
'API 钱包地址 - 从 https://www.asterdex.com/zh-CN/api-wallet 生成',
asterPrivateKeyDesc:
'API 钱包私钥 - 从 https://www.asterdex.com/zh-CN/api-wallet 获取(仅在本地用于签名,不会被传输)',
asterUsdtWarning:
'重要提示:Aster 仅统计 USDT 余额。请确保您使用 USDT 作为保证金币种,避免其他资产(BNB、ETH等)的价格波动导致盈亏统计错误',
testnetDescription: '启用后将连接到交易所测试环境,用于模拟交易',
securityWarning: '安全提示',
saveConfiguration: '保存配置',
@@ -626,13 +689,15 @@ export const translations = {
altcoinLeverage: '山寨币杠杆',
leverageRecommendation: '推荐:BTC/ETH 5-10倍,山寨币 3-5倍,控制风险',
tradingSymbols: '交易币种',
tradingSymbolsPlaceholder: '输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT',
tradingSymbolsPlaceholder:
'输入币种,逗号分隔(如:BTCUSDT,ETHUSDT,SOLUSDT',
selectSymbols: '选择币种',
selectTradingSymbols: '选择交易币种',
selectedSymbolsCount: '已选择 {count} 个币种',
clearSelection: '清空选择',
confirmSelection: '确认选择',
tradingSymbolsDescription: '留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT',
tradingSymbolsDescription:
'留空 = 使用默认币种。必须以USDT结尾(如:BTCUSDT, ETHUSDT',
btcEthLeverageValidation: 'BTC/ETH杠杆必须在1-50倍之间',
altcoinLeverageValidation: '山寨币杠杆必须在1-20倍之间',
invalidSymbolFormat: '无效的币种格式:{symbol},必须以USDT结尾',
@@ -651,7 +716,8 @@ export const translations = {
coinPoolDescription: '用于获取币种池数据的API地址,留空则不使用此信号源',
oiTopDescription: '用于获取持仓量排行数据的API地址,留空则不使用此信号源',
information: '说明',
signalSourceInfo1: '• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
signalSourceInfo1:
'• 信号源配置为用户级别,每个用户可以设置自己的信号源URL',
signalSourceInfo2: '• 在创建交易员时可以选择是否使用这些信号源',
signalSourceInfo3: '• 配置的URL将用于获取市场数据和交易信号',
editAIModel: '编辑AI模型',
@@ -692,7 +758,7 @@ export const translations = {
exchangeNotExist: '交易所不存在',
deleteExchangeConfigFailed: '删除交易所配置失败',
saveSignalSourceFailed: '保存信号源配置失败',
// Login & Register
login: '登录',
register: '注册',
@@ -754,20 +820,22 @@ export const translations = {
exitLogin: '退出登录',
signIn: '登录',
signUp: '注册',
// Hero Section
githubStarsInDays: '3 天内 2.5K+ GitHub Stars',
heroTitle1: 'Read the Market.',
heroTitle2: 'Write the Trade.',
heroDescription: 'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
heroDescription:
'NOFX 是 AI 交易的未来标准——一个开放、社区驱动的代理式交易操作系统。支持 Binance、Aster DEX 等交易所,自托管、多代理竞争,让 AI 为你自动决策、执行和优化交易。',
poweredBy: '由 Aster DEX 和 Binance 提供支持,Amber.ac 战略投资。',
// Landing Page CTA
readyToDefine: '准备好定义 AI 交易的未来吗?',
startWithCrypto: '从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
startWithCrypto:
'从加密市场起步,扩展到 TradFi。NOFX 是 AgentFi 的基础架构。',
getStartedNow: '立即开始',
viewSourceCode: '查看源码',
// Features Section
coreFeatures: '核心功能',
whyChooseNofx: '为什么选择 NOFX',
@@ -790,38 +858,44 @@ export const translations = {
secureFeatures2: 'API 权限精细控制',
secureFeatures3: '实时风险监控',
secureFeatures4: '交易日志审计',
// About Section
aboutNofx: '关于 NOFX',
whatIsNofx: '什么是 NOFX',
nofxNotAnotherBot: 'NOFX 不是另一个交易机器人,而是 AI 交易的 \'Linux\' ——',
nofxDescription1: '一个透明、可信任的开源 OS,提供统一的 \'决策-风险-执行\'',
nofxNotAnotherBot: "NOFX 不是另一个交易机器人,而是 AI 交易的 'Linux' ——",
nofxDescription1: "一个透明、可信任的开源 OS,提供统一的 '决策-风险-执行'",
nofxDescription2: '层,支持所有资产类别。',
nofxDescription3: '从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
nofxDescription4: '达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
nofxDescription3:
'从加密市场起步(24/7、高波动性完美测试场),未来扩展到股票、期货、外汇。核心:开放架构、AI',
nofxDescription4:
'达尔文主义(多代理自竞争、策略进化)、CodeFi 飞轮(开发者 PR',
nofxDescription5: '贡献获积分奖励)。',
youFullControl: '你 100% 掌控',
fullControlDesc: '完全掌控 AI 提示词和资金',
startupMessages1: ' 启动自动交易系统...',
startupMessages2: ' API服务器启动在端口 8080',
startupMessages3: ' Web 控制台 http://localhost:3000',
startupMessages1: '启动自动交易系统...',
startupMessages2: 'API服务器启动在端口 8080',
startupMessages3: 'Web 控制台 http://localhost:3000',
// How It Works Section
howToStart: '如何开始使用 NOFX',
fourSimpleSteps: '四个简单步骤,开启 AI 自动交易之旅',
step1Title: '拉取 GitHub 仓库',
step1Desc: 'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
step1Desc:
'git clone https://github.com/tinkle-community/nofx 并切换到 dev 分支测试新功能。',
step2Title: '配置环境',
step2Desc: '前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
step2Desc:
'前端设置交易所 API(如 Binance、Hyperliquid)、AI 模型和自定义提示词。',
step3Title: '部署与运行',
step3Desc: '一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
step3Desc:
'一键 Docker 部署,启动 AI 代理。注意:高风险市场,仅用闲钱测试。',
step4Title: '优化与贡献',
step4Desc: '监控交易,提交 PR 改进框架。加入 Telegram 分享策略。',
importantRiskWarning: '重要风险提示',
riskWarningText: 'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
riskWarningText:
'dev 分支不稳定,勿用无法承受损失的资金。NOFX 非托管,无官方策略。交易有风险,投资需谨慎。',
// Community Section (testimonials are kept as-is since they are quotes)
// Footer Section
futureStandardAI: 'AI 交易的未来标准',
links: '链接',
@@ -829,23 +903,27 @@ export const translations = {
documentation: '文档',
supporters: '支持方',
strategicInvestment: '(战略投资)',
// Login Modal
accessNofxPlatform: '访问 NOFX 平台',
loginRegisterPrompt: '请选择登录或注册以访问完整的 AI 交易平台',
registerNewAccount: '注册新账号',
}
};
},
}
export function t(key: string, lang: Language, params?: Record<string, string | number>): string {
let text = translations[lang][key as keyof typeof translations['en']] || key;
export function t(
key: string,
lang: Language,
params?: Record<string, string | number>
): string {
let text = translations[lang][key as keyof (typeof translations)['en']] || key
// Replace parameters like {count}, {gap}, etc.
if (params) {
Object.entries(params).forEach(([param, value]) => {
text = text.replace(`{${param}}`, String(value));
});
text = text.replace(`{${param}}`, String(value))
})
}
return text;
return text
}
+55 -32
View File
@@ -12,54 +12,62 @@ html {
:root {
/* Binance Brand Colors */
--brand-yellow: #F0B90B;
--brand-yellow: #f0b90b;
--brand-black: #000000;
--brand-dark-gray: #0A0A0A;
--brand-light-gray: #EAECEF;
--brand-almost-white: #FAFAFA;
--brand-white: #FFFFFF;
--brand-dark-gray: #0a0a0a;
--brand-light-gray: #eaecef;
--brand-almost-white: #fafafa;
--brand-white: #ffffff;
/* Binance Theme Colors */
--binance-yellow: #F0B90B;
--binance-yellow-dark: #C99400;
--binance-yellow-light: #FCD535;
--binance-yellow: #f0b90b;
--binance-yellow-dark: #c99400;
--binance-yellow-light: #fcd535;
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
--background: #000000; /* Binance body bg */
--header-bg: #000000; /* Binance header bg */
--header-bg: #000000; /* Binance header bg */
--background-elevated: #000000;
--foreground: #EAECEF;
--panel-bg: #0A0A0A;
--foreground: #eaecef;
--panel-bg: #0a0a0a;
--panel-bg-hover: #111111;
--panel-border: #1A1A1A;
--panel-border-hover: #2A2A2A;
--panel-border: #1a1a1a;
--panel-border-hover: #2a2a2a;
/* Binance Signature Colors */
--binance-green: #0ECB81;
--binance-green: #0ecb81;
--binance-green-bg: rgba(14, 203, 129, 0.1);
--binance-green-border: rgba(14, 203, 129, 0.2);
--binance-red: #F6465D;
--binance-red: #f6465d;
--binance-red-bg: rgba(246, 70, 93, 0.1);
--binance-red-border: rgba(246, 70, 93, 0.2);
/* UI Colors */
--text-primary: #EAECEF;
--text-secondary: #848E9C;
--text-tertiary: #5E6673;
--text-disabled: #474D57;
--text-primary: #eaecef;
--text-secondary: #848e9c;
--text-tertiary: #5e6673;
--text-disabled: #474d57;
/* Chart Colors */
--grid-stroke: #1A1A1A;
--axis-tick: #5E6673;
--ref-line: #474D57;
--grid-stroke: #1a1a1a;
--axis-tick: #5e6673;
--ref-line: #474d57;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
line-height: 1.6;
font-weight: 400;
color-scheme: dark;
@@ -69,7 +77,7 @@ html {
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum";
font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums;
}
@@ -140,7 +148,8 @@ body {
}
@keyframes pulse-glow {
0%, 100% {
0%,
100% {
opacity: 1;
box-shadow: 0 0 8px currentColor;
}
@@ -160,7 +169,8 @@ body {
}
@keyframes pulse-scale {
0%, 100% {
0%,
100% {
transform: scale(1);
}
50% {
@@ -240,11 +250,19 @@ button:disabled {
/* Binance gradient backgrounds */
.binance-gradient {
background: linear-gradient(135deg, var(--binance-yellow) 0%, var(--binance-yellow-light) 100%);
background: linear-gradient(
135deg,
var(--binance-yellow) 0%,
var(--binance-yellow-light) 100%
);
}
.binance-gradient-subtle {
background: linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%);
background: linear-gradient(
135deg,
rgba(240, 185, 11, 0.15) 0%,
rgba(252, 213, 53, 0.05) 100%
);
border: 1px solid rgba(240, 185, 11, 0.2);
}
@@ -456,7 +474,12 @@ tr:hover {
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--binance-yellow), transparent);
background: linear-gradient(
90deg,
transparent,
var(--binance-yellow),
transparent
);
opacity: 0;
transition: opacity 0.2s ease;
}
+119 -105
View File
@@ -11,22 +11,22 @@ import type {
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types';
} from '../types'
const API_BASE = '/api';
const API_BASE = '/api'
// Helper function to get auth headers
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token');
const token = localStorage.getItem('auth_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
export const api = {
@@ -34,16 +34,16 @@ export const api = {
async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/my-traders`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取trader列表失败');
return res.json();
})
if (!res.ok) throw new Error('获取trader列表失败')
return res.json()
},
// 获取公开的交易员列表(无需认证)
async getPublicTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/traders`);
if (!res.ok) throw new Error('获取公开trader列表失败');
return res.json();
const res = await fetch(`${API_BASE}/traders`)
if (!res.ok) throw new Error('获取公开trader列表失败')
return res.json()
},
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
@@ -51,76 +51,82 @@ export const api = {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('创建交易员失败');
return res.json();
})
if (!res.ok) throw new Error('创建交易员失败')
return res.json()
},
async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('删除交易员失败');
})
if (!res.ok) throw new Error('删除交易员失败')
},
async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('启动交易员失败');
})
if (!res.ok) throw new Error('启动交易员失败')
},
async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, {
method: 'POST',
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('停止交易员失败');
})
if (!res.ok) throw new Error('停止交易员失败')
},
async updateTraderPrompt(traderId: string, customPrompt: string): Promise<void> {
async updateTraderPrompt(
traderId: string,
customPrompt: string
): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ custom_prompt: customPrompt }),
});
if (!res.ok) throw new Error('更新自定义策略失败');
})
if (!res.ok) throw new Error('更新自定义策略失败')
},
async getTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易员配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取交易员配置失败')
return res.json()
},
async updateTrader(traderId: string, request: CreateTraderRequest): Promise<TraderInfo> {
async updateTrader(
traderId: string,
request: CreateTraderRequest
): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易员失败');
return res.json();
})
if (!res.ok) throw new Error('更新交易员失败')
return res.json()
},
// AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取模型配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取模型配置失败')
return res.json()
},
// 获取系统支持的AI模型列表(无需认证)
async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`);
if (!res.ok) throw new Error('获取支持的模型失败');
return res.json();
const res = await fetch(`${API_BASE}/supported-models`)
if (!res.ok) throw new Error('获取支持的模型失败')
return res.json()
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
@@ -128,123 +134,125 @@ export const api = {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新模型配置失败');
})
if (!res.ok) throw new Error('更新模型配置失败')
},
// 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取交易所配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取交易所配置失败')
return res.json()
},
// 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`);
if (!res.ok) throw new Error('获取支持的交易所失败');
return res.json();
const res = await fetch(`${API_BASE}/supported-exchanges`)
if (!res.ok) throw new Error('获取支持的交易所失败')
return res.json()
},
async updateExchangeConfigs(request: UpdateExchangeConfigRequest): Promise<void> {
async updateExchangeConfigs(
request: UpdateExchangeConfigRequest
): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
});
if (!res.ok) throw new Error('更新交易所配置失败');
})
if (!res.ok) throw new Error('更新交易所配置失败')
},
// 获取系统状态(支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`;
: `${API_BASE}/status`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取系统状态失败');
return res.json();
})
if (!res.ok) throw new Error('获取系统状态失败')
return res.json()
},
// 获取账户信息(支持trader_id
async getAccount(traderId?: string): Promise<AccountInfo> {
const url = traderId
? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`;
: `${API_BASE}/account`
const res = await fetch(url, {
cache: 'no-store',
headers: {
...getAuthHeaders(),
'Cache-Control': 'no-cache',
},
});
if (!res.ok) throw new Error('获取账户信息失败');
const data = await res.json();
console.log('Account data fetched:', data);
return data;
})
if (!res.ok) throw new Error('获取账户信息失败')
const data = await res.json()
console.log('Account data fetched:', data)
return data
},
// 获取持仓列表(支持trader_id
async getPositions(traderId?: string): Promise<Position[]> {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`;
: `${API_BASE}/positions`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取持仓列表失败');
return res.json();
})
if (!res.ok) throw new Error('获取持仓列表失败')
return res.json()
},
// 获取决策日志(支持trader_id
async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`;
: `${API_BASE}/decisions`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取决策日志失败');
return res.json();
})
if (!res.ok) throw new Error('获取决策日志失败')
return res.json()
},
// 获取最新决策(支持trader_id
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`;
: `${API_BASE}/decisions/latest`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取最新决策失败');
return res.json();
})
if (!res.ok) throw new Error('获取最新决策失败')
return res.json()
},
// 获取统计信息(支持trader_id
async getStatistics(traderId?: string): Promise<Statistics> {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`;
: `${API_BASE}/statistics`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取统计信息失败');
return res.json();
})
if (!res.ok) throw new Error('获取统计信息失败')
return res.json()
},
// 获取收益率历史数据(支持trader_id)
async getEquityHistory(traderId?: string): Promise<any[]> {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`;
: `${API_BASE}/equity-history`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取历史数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取历史数据失败')
return res.json()
},
// 批量获取多个交易员的历史数据(无需认证)
@@ -255,54 +263,60 @@ export const api = {
'Content-Type': 'application/json',
},
body: JSON.stringify({ trader_ids: traderIds }),
});
if (!res.ok) throw new Error('获取批量历史数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取批量历史数据失败')
return res.json()
},
// 获取前5名交易员数据(无需认证)
async getTopTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/top-traders`);
if (!res.ok) throw new Error('获取前5名交易员失败');
return res.json();
const res = await fetch(`${API_BASE}/top-traders`)
if (!res.ok) throw new Error('获取前5名交易员失败')
return res.json()
},
// 获取公开交易员配置(无需认证)
async getPublicTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/trader/${traderId}/config`);
if (!res.ok) throw new Error('获取公开交易员配置失败');
return res.json();
const res = await fetch(`${API_BASE}/trader/${traderId}/config`)
if (!res.ok) throw new Error('获取公开交易员配置失败')
return res.json()
},
// 获取AI学习表现分析(支持trader_id
async getPerformance(traderId?: string): Promise<any> {
const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance`;
: `${API_BASE}/performance`
const res = await fetch(url, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取AI学习数据失败');
return res.json();
})
if (!res.ok) throw new Error('获取AI学习数据失败')
return res.json()
},
// 获取竞赛数据(无需认证)
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`);
if (!res.ok) throw new Error('获取竞赛数据失败');
return res.json();
const res = await fetch(`${API_BASE}/competition`)
if (!res.ok) throw new Error('获取竞赛数据失败')
return res.json()
},
// 用户信号源配置接口
async getUserSignalSource(): Promise<{coin_pool_url: string, oi_top_url: string}> {
async getUserSignalSource(): Promise<{
coin_pool_url: string
oi_top_url: string
}> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取用户信号源配置失败');
return res.json();
})
if (!res.ok) throw new Error('获取用户信号源配置失败')
return res.json()
},
async saveUserSignalSource(coinPoolUrl: string, oiTopUrl: string): Promise<void> {
async saveUserSignalSource(
coinPoolUrl: string,
oiTopUrl: string
): Promise<void> {
const res = await fetch(`${API_BASE}/user/signal-sources`, {
method: 'POST',
headers: getAuthHeaders(),
@@ -310,7 +324,7 @@ export const api = {
coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl,
}),
});
if (!res.ok) throw new Error('保存用户信号源配置失败');
})
if (!res.ok) throw new Error('保存用户信号源配置失败')
},
};
}
+10 -12
View File
@@ -1,28 +1,26 @@
export interface SystemConfig {
admin_mode: boolean;
beta_mode: boolean;
admin_mode: boolean
beta_mode: boolean
}
let configPromise: Promise<SystemConfig> | null = null;
let cachedConfig: SystemConfig | null = null;
let configPromise: Promise<SystemConfig> | null = null
let cachedConfig: SystemConfig | null = null
export function getSystemConfig(): Promise<SystemConfig> {
if (cachedConfig) {
return Promise.resolve(cachedConfig);
return Promise.resolve(cachedConfig)
}
if (configPromise) {
return configPromise;
return configPromise
}
configPromise = fetch('/api/config')
.then((res) => res.json())
.then((data: SystemConfig) => {
cachedConfig = data;
return data;
cachedConfig = data
return data
})
.finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
});
return configPromise;
})
return configPromise
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -6,5 +6,5 @@ import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
</React.StrictMode>
)
+89 -38
View File
@@ -19,61 +19,112 @@ export function LandingPage() {
const { user, logout } = useAuth()
const { language, setLanguage } = useLanguage()
const isLoggedIn = !!user
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn);
console.log('LandingPage - user:', user, 'isLoggedIn:', isLoggedIn)
return (
<>
<HeaderBar
onLoginClick={() => setShowLoginModal(true)}
isLoggedIn={isLoggedIn}
<HeaderBar
onLoginClick={() => setShowLoginModal(true)}
isLoggedIn={isLoggedIn}
isHomePage={true}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onPageChange={(page) => {
console.log('LandingPage onPageChange called with:', page);
console.log('LandingPage onPageChange called with:', page)
if (page === 'competition') {
window.location.href = '/competition';
window.location.href = '/competition'
} else if (page === 'traders') {
window.location.href = '/traders';
window.location.href = '/traders'
} else if (page === 'trader') {
window.location.href = '/dashboard';
window.location.href = '/dashboard'
}
}}
/>
<div className='min-h-screen px-4 sm:px-6 lg:px-8' style={{ background: 'var(--brand-black)', color: 'var(--brand-light-gray)' }}>
<HeroSection language={language} />
<AboutSection language={language} />
<FeaturesSection language={language} />
<HowItWorksSection language={language} />
<CommunitySection />
<div
className="min-h-screen px-4 sm:px-6 lg:px-8"
style={{
background: 'var(--brand-black)',
color: 'var(--brand-light-gray)',
}}
>
<HeroSection language={language} />
<AboutSection language={language} />
<FeaturesSection language={language} />
<HowItWorksSection language={language} />
<CommunitySection />
{/* CTA */}
<AnimatedSection backgroundColor='var(--panel-bg)'>
<div className='max-w-4xl mx-auto text-center'>
<motion.h2 className='text-5xl font-bold mb-6' style={{ color: 'var(--brand-light-gray)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
{t('readyToDefine', language)}
</motion.h2>
<motion.p className='text-xl mb-12' style={{ color: 'var(--text-secondary)' }} initial={{ opacity: 0, y: 30 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ delay: 0.1 }}>
{t('startWithCrypto', language)}
</motion.p>
<div className='flex flex-wrap justify-center gap-4'>
<motion.button onClick={() => setShowLoginModal(true)} className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'var(--brand-yellow)', color: 'var(--brand-black)' }} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
{t('getStartedNow', language)}
<motion.div animate={{ x: [0, 5, 0] }} transition={{ duration: 1.5, repeat: Infinity }}>
<ArrowRight className='w-5 h-5' />
</motion.div>
</motion.button>
<motion.a href='https://github.com/tinkle-community/nofx/tree/dev' target='_blank' rel='noopener noreferrer' className='flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg' style={{ background: 'transparent', color: 'var(--brand-light-gray)', border: '2px solid var(--brand-yellow)' }} whileHover={{ scale: 1.05, backgroundColor: 'rgba(240, 185, 11, 0.1)' }} whileTap={{ scale: 0.95 }}>
{t('viewSourceCode', language)}
</motion.a>
{/* CTA */}
<AnimatedSection backgroundColor="var(--panel-bg)">
<div className="max-w-4xl mx-auto text-center">
<motion.h2
className="text-5xl font-bold mb-6"
style={{ color: 'var(--brand-light-gray)' }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
>
{t('readyToDefine', language)}
</motion.h2>
<motion.p
className="text-xl mb-12"
style={{ color: 'var(--text-secondary)' }}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
>
{t('startWithCrypto', language)}
</motion.p>
<div className="flex flex-wrap justify-center gap-4">
<motion.button
onClick={() => setShowLoginModal(true)}
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{t('getStartedNow', language)}
<motion.div
animate={{ x: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
<ArrowRight className="w-5 h-5" />
</motion.div>
</motion.button>
<motion.a
href="https://github.com/tinkle-community/nofx/tree/dev"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-10 py-4 rounded-lg font-semibold text-lg"
style={{
background: 'transparent',
color: 'var(--brand-light-gray)',
border: '2px solid var(--brand-yellow)',
}}
whileHover={{
scale: 1.05,
backgroundColor: 'rgba(240, 185, 11, 0.1)',
}}
whileTap={{ scale: 0.95 }}
>
{t('viewSourceCode', language)}
</motion.a>
</div>
</div>
</div>
</AnimatedSection>
</AnimatedSection>
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} language={language} />}
<FooterSection language={language} />
{showLoginModal && (
<LoginModal
onClose={() => setShowLoginModal(false)}
language={language}
/>
)}
<FooterSection language={language} />
</div>
</>
)
+146 -146
View File
@@ -1,204 +1,204 @@
export interface SystemStatus {
trader_id: string;
trader_name: string;
ai_model: string;
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
trader_id: string
trader_name: string
ai_model: string
is_running: boolean
start_time: string
runtime_minutes: number
call_count: number
initial_balance: number
scan_interval: string
stop_until: string
last_reset_time: string
ai_provider: string
}
export interface AccountInfo {
total_equity: number;
wallet_balance: number;
unrealized_profit: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
initial_balance: number;
daily_pnl: number;
position_count: number;
margin_used: number;
margin_used_pct: number;
total_equity: number
wallet_balance: number
unrealized_profit: number
available_balance: number
total_pnl: number
total_pnl_pct: number
total_unrealized_pnl: number
initial_balance: number
daily_pnl: number
position_count: number
margin_used: number
margin_used_pct: number
}
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
symbol: string
side: string
entry_price: number
mark_price: number
quantity: number
leverage: number
unrealized_pnl: number
unrealized_pnl_pct: number
liquidation_price: number
margin_used: number
}
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error?: string;
action: string
symbol: string
quantity: number
leverage: number
price: number
order_id: number
timestamp: string
success: boolean
error?: string
}
export interface AccountSnapshot {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
total_balance: number
available_balance: number
total_unrealized_profit: number
position_count: number
margin_used_pct: number
}
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
input_prompt: string;
cot_trace: string;
decision_json: string;
account_state: AccountSnapshot;
positions: any[];
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message?: string;
timestamp: string
cycle_number: number
input_prompt: string
cot_trace: string
decision_json: string
account_state: AccountSnapshot
positions: any[]
candidate_coins: string[]
decisions: DecisionAction[]
execution_log: string[]
success: boolean
error_message?: string
}
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
total_cycles: number
successful_cycles: number
failed_cycles: number
total_open_positions: number
total_close_positions: number
}
// AI Trading相关类型
export interface TraderInfo {
trader_id: string;
trader_name: string;
ai_model: string;
exchange_id?: string;
is_running?: boolean;
custom_prompt?: string;
trader_id: string
trader_name: string
ai_model: string
exchange_id?: string
is_running?: boolean
custom_prompt?: string
}
export interface AIModel {
id: string;
name: string;
provider: string;
enabled: boolean;
apiKey?: string;
customApiUrl?: string;
customModelName?: string;
id: string
name: string
provider: string
enabled: boolean
apiKey?: string
customApiUrl?: string
customModelName?: string
}
export interface Exchange {
id: string;
name: string;
type: 'cex' | 'dex';
enabled: boolean;
apiKey?: string;
secretKey?: string;
testnet?: boolean;
id: string
name: string
type: 'cex' | 'dex'
enabled: boolean
apiKey?: string
secretKey?: string
testnet?: boolean
// Hyperliquid 特定字段
hyperliquidWalletAddr?: string;
hyperliquidWalletAddr?: string
// Aster 特定字段
asterUser?: string;
asterSigner?: string;
asterPrivateKey?: string;
asterUser?: string
asterSigner?: string
asterPrivateKey?: string
}
export interface CreateTraderRequest {
name: string;
ai_model_id: string;
exchange_id: string;
initial_balance: number;
scan_interval_minutes?: number;
btc_eth_leverage?: number;
altcoin_leverage?: number;
trading_symbols?: string;
custom_prompt?: string;
override_base_prompt?: boolean;
system_prompt_template?: string;
is_cross_margin?: boolean;
use_coin_pool?: boolean;
use_oi_top?: boolean;
name: string
ai_model_id: string
exchange_id: string
initial_balance: number
scan_interval_minutes?: number
btc_eth_leverage?: number
altcoin_leverage?: number
trading_symbols?: string
custom_prompt?: string
override_base_prompt?: boolean
system_prompt_template?: string
is_cross_margin?: boolean
use_coin_pool?: boolean
use_oi_top?: boolean
}
export interface UpdateModelConfigRequest {
models: {
[key: string]: {
enabled: boolean;
api_key: string;
custom_api_url?: string;
custom_model_name?: string;
};
};
enabled: boolean
api_key: string
custom_api_url?: string
custom_model_name?: string
}
}
}
export interface UpdateExchangeConfigRequest {
exchanges: {
[key: string]: {
enabled: boolean;
api_key: string;
secret_key: string;
testnet?: boolean;
enabled: boolean
api_key: string
secret_key: string
testnet?: boolean
// Hyperliquid 特定字段
hyperliquid_wallet_addr?: string;
hyperliquid_wallet_addr?: string
// Aster 特定字段
aster_user?: string;
aster_signer?: string;
aster_private_key?: string;
};
};
aster_user?: string
aster_signer?: string
aster_private_key?: string
}
}
}
// Competition related types
export interface CompetitionTraderData {
trader_id: string;
trader_name: string;
ai_model: string;
exchange: string;
total_equity: number;
total_pnl: number;
total_pnl_pct: number;
position_count: number;
margin_used_pct: number;
is_running: boolean;
trader_id: string
trader_name: string
ai_model: string
exchange: string
total_equity: number
total_pnl: number
total_pnl_pct: number
position_count: number
margin_used_pct: number
is_running: boolean
}
export interface CompetitionData {
traders: CompetitionTraderData[];
count: number;
traders: CompetitionTraderData[]
count: number
}
// Trader Configuration Data for View Modal
export interface TraderConfigData {
trader_id?: string;
trader_name: string;
ai_model: string;
exchange_id: string;
btc_eth_leverage: number;
altcoin_leverage: number;
trading_symbols: string;
custom_prompt: string;
override_base_prompt: boolean;
is_cross_margin: boolean;
use_coin_pool: boolean;
use_oi_top: boolean;
initial_balance: number;
scan_interval_minutes: number;
is_running: boolean;
trader_id?: string
trader_name: string
ai_model: string
exchange_id: string
btc_eth_leverage: number
altcoin_leverage: number
trading_symbols: string
custom_prompt: string
override_base_prompt: boolean
is_cross_margin: boolean
use_coin_pool: boolean
use_oi_top: boolean
initial_balance: number
scan_interval_minutes: number
is_running: boolean
}
+68 -68
View File
@@ -1,93 +1,93 @@
// 系统状态
export interface SystemStatus {
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
is_running: boolean
start_time: string
runtime_minutes: number
call_count: number
initial_balance: number
scan_interval: string
stop_until: string
last_reset_time: string
ai_provider: string
}
// 账户信息
export interface AccountInfo {
total_equity: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
margin_used: number;
margin_used_pct: number;
position_count: number;
initial_balance: number;
daily_pnl: number;
total_equity: number
available_balance: number
total_pnl: number
total_pnl_pct: number
total_unrealized_pnl: number
margin_used: number
margin_used_pct: number
position_count: number
initial_balance: number
daily_pnl: number
}
// 持仓信息
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
symbol: string
side: string
entry_price: number
mark_price: number
quantity: number
leverage: number
unrealized_pnl: number
unrealized_pnl_pct: number
liquidation_price: number
margin_used: number
}
// 决策动作
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error: string;
action: string
symbol: string
quantity: number
leverage: number
price: number
order_id: number
timestamp: string
success: boolean
error: string
}
// 决策记录
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
input_prompt: string;
cot_trace: string;
decision_json: string;
timestamp: string
cycle_number: number
input_prompt: string
cot_trace: string
decision_json: string
account_state: {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
};
total_balance: number
available_balance: number
total_unrealized_profit: number
position_count: number
margin_used_pct: number
}
positions: Array<{
symbol: string;
side: string;
position_amt: number;
entry_price: number;
mark_price: number;
unrealized_profit: number;
leverage: number;
liquidation_price: number;
}>;
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message: string;
symbol: string
side: string
position_amt: number
entry_price: number
mark_price: number
unrealized_profit: number
leverage: number
liquidation_price: number
}>
candidate_coins: string[]
decisions: DecisionAction[]
execution_log: string[]
success: boolean
error_message: string
}
// 统计信息
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
total_cycles: number
successful_cycles: number
failed_cycles: number
total_open_positions: number
total_close_positions: number
}
+4 -4
View File
@@ -12,7 +12,7 @@ export const TRADER_COLORS = [
'#a78bfa', // violet-400
'#4ade80', // green-400
'#fb7185', // rose-400
];
]
/**
* trader的索引位置获取颜色
@@ -24,8 +24,8 @@ export function getTraderColor(
traders: Array<{ trader_id: string }>,
traderId: string
): string {
const traderIndex = traders.findIndex((t) => t.trader_id === traderId);
if (traderIndex === -1) return TRADER_COLORS[0]; // 默认返回第一个颜色
const traderIndex = traders.findIndex((t) => t.trader_id === traderId)
if (traderIndex === -1) return TRADER_COLORS[0] // 默认返回第一个颜色
// 如果超出颜色池大小,循环使用
return TRADER_COLORS[traderIndex % TRADER_COLORS.length];
return TRADER_COLORS[traderIndex % TRADER_COLORS.length]
}